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 6401ae0b1f8c..dc97b70443ba 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 @@ -453,6 +453,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE ], }] }] }); +export class TestCmpRenderProps { + title = 'Submit'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpRenderProps, isStandalone: true, selector: "main-render-props", ngImport: i0, template: ` + + @content(items; let item, index) { + #{{index}}: {{item}} + } + + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpRenderProps, decorators: [{ + type: Component, + args: [{ + selector: 'main-render-props', + template: ` + + @content(items; let item, index) { + #{{index}}: {{item}} + } + + `, + // @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 @@ -469,4 +498,9 @@ export declare class TestCmpChildren { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; } +export declare class TestCmpRenderProps { + 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 fcc01f41c713..664f97f63539 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 @@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) { } } +function TestCmpRenderProps_Items_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomElementStart(0, "span"); + i0.ɵɵtext(1); + i0.ɵɵdomElementEnd(); + } + if (rf & 2) { + const item_r1 = ctx[0]; + const index_r2 = ctx[1]; + i0.ɵɵadvance(); + i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1); + } +} + … export class TestCmp { @@ -31,9 +45,10 @@ export class TestCmp { selectors: [["main"]], decls: 1, vars: 0, + consts: [frameworkImport(FancyButton)], template: function TestCmp_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title }); + i0.ɵɵforeignComponent(0, 0, { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title }); } }, encapsulation: 2 @@ -49,12 +64,34 @@ export class TestCmpChildren { selectors: [["main-children"]], decls: 4, vars: 0, + consts: [frameworkImport(FancyButton)], 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) }); + i0.ɵɵforeignComponent(3, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) }); + } + }, + encapsulation: 2 + }); +} + +… + +export class TestCmpRenderProps { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpRenderProps, + selectors: [["main-render-props"]], + decls: 2, + vars: 0, + consts: [frameworkImport(FancyButton)], + template: function TestCmpRenderProps_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomTemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2); + i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0, 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 53919dd68262..9c3f8e8c4952 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 @@ -22,6 +22,20 @@ function TestCmpChildren_Children_2_Template(rf, ctx) { } } +function TestCmpRenderProps_Items_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵelementStart(0, "span"); + i0.ɵɵtext(1); + i0.ɵɵelementEnd(); + } + if (rf & 2) { + const item_r1 = ctx[0]; + const index_r2 = ctx[1]; + i0.ɵɵadvance(); + i0.ɵɵtextInterpolate2("#", index_r2, ": ", item_r1); + } +} + … export class TestCmp { @@ -31,9 +45,10 @@ export class TestCmp { selectors: [["main"]], decls: 1, vars: 0, + consts: [frameworkImport(FancyButton)], template: function TestCmp_Template(rf, ctx) { if (rf & 1) { - i0.ɵɵforeignComponent(0, frameworkImport(FancyButton), { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title }); + i0.ɵɵforeignComponent(0, 0, { class: "btn-cls", "unsafe-attr": "value", label: ctx.title, "unsafe-input": ctx.title }); } }, encapsulation: 2 @@ -49,10 +64,31 @@ export class TestCmpChildren { selectors: [["main-children"]], decls: 4, vars: 0, + consts: [frameworkImport(FancyButton)], 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) }); + i0.ɵɵforeignComponent(3, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) }); + } + }, + encapsulation: 2 + }); +} + +… + +export class TestCmpRenderProps { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpRenderProps, + selectors: [["main-render-props"]], + decls: 2, + vars: 0, + consts: [frameworkImport(FancyButton)], + template: function TestCmpRenderProps_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵtemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2); + i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0, 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 9a48a1605da4..fc10408ee531 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 @@ -50,3 +50,22 @@ export class TestCmpChildren { title = 'Submit'; } +@Component({ + selector: 'main-render-props', + template: ` + + @content(items; let item, index) { + #{{index}}: {{item}} + } + + `, + // @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 TestCmpRenderProps { + title = 'Submit'; +} + diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 8baf78f1be1c..74fc2efecf9b 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -342,6 +342,7 @@ export class DeferredBlockError extends BlockNode implements Node { export class ContentBlock extends BlockNode implements Node { constructor( public name: string, + public variables: Variable[], public children: Node[], nameSpan: ParseSourceSpan, sourceSpan: ParseSourceSpan, diff --git a/packages/compiler/src/render3/r3_content_blocks.ts b/packages/compiler/src/render3/r3_content_blocks.ts index c00c9cf2084b..8eb5eeb62a5b 100644 --- a/packages/compiler/src/render3/r3_content_blocks.ts +++ b/packages/compiler/src/render3/r3_content_blocks.ts @@ -7,10 +7,10 @@ */ import * as html from '../ml_parser/ast'; -import {ParseError} from '../parse_util'; +import {ParseError, ParseSourceSpan} from '../parse_util'; import * as t from './r3_ast'; -import {IDENTIFIER_PATTERN} from './util'; +import {IDENTIFIER_PATTERN, LET_PATTERN} from './util'; /** Creates a content block from an HTML AST node. */ export function createContentBlock( @@ -18,37 +18,41 @@ export function createContentBlock( visitor: html.Visitor, ): {node: t.ContentBlock | null; errors: ParseError[]} { const errors: ParseError[] = []; - if (ast.parameters.length !== 1) { + if (ast.parameters.length < 1 || ast.parameters.length > 2) { errors.push( - new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'), + new ParseError( + ast.startSourceSpan, + '@content block must have one or two parameters, e.g. ' + + '"@content(header)" or "@content(items; let item, index)"', + ), ); 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] === '') { + const nameParam = ast.parameters[0]; + const name = nameParam.expression.trim(); + if (name.includes(',')) { errors.push( - new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'), + new ParseError(ast.startSourceSpan, '@content block must have exactly one name 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'), + new ParseError(nameParam.sourceSpan, '@content name must be a valid JavaScript identifier'), ); return {node: null, errors}; } + const variables = parseContentBlockVariables(ast, errors); + if (variables === null) { + return {node: null, errors}; + } + const node = new t.ContentBlock( name, + variables, html.visitAll(visitor, ast.children, ast.children), ast.nameSpan, ast.sourceSpan, @@ -58,3 +62,81 @@ export function createContentBlock( ); return {node, errors}; } + +/** Parses the variables of a content block. */ +function parseContentBlockVariables(ast: html.Block, errors: ParseError[]): t.Variable[] | null { + const variables: t.Variable[] = []; + if (ast.parameters.length < 2) { + return variables; + } + + const varsParam = ast.parameters[1]; + const varsExpr = varsParam.expression.trim(); + const letMatch = varsExpr.match(LET_PATTERN); + if (!letMatch) { + errors.push( + new ParseError( + varsParam.sourceSpan, + '@content block variables must start with "let", e.g. "let item, index"', + ), + ); + return null; + } + + const varNames = letMatch[1].split(',').map((v) => v.trim()); + const variablesRawString = letMatch[1]; + const variablesStartOffset = varsParam.expression.indexOf(variablesRawString); + const variablesStartLocation = varsParam.sourceSpan.start.moveBy(variablesStartOffset); + + let searchIndex = 0; + for (let varName of varNames) { + if (varName === '') { + errors.push(new ParseError(varsParam.sourceSpan, 'Invalid variable name in @content block')); + continue; + } + + let varSpan: ParseSourceSpan; + const index = variablesRawString.indexOf(varName, searchIndex); + const varStart = variablesStartLocation.moveBy(index); + + if (varName.includes('=')) { + const eqIndex = varName.indexOf('='); + const namePart = varName.substring(0, eqIndex).trim(); + const fullVarSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length)); + + errors.push( + new ParseError(fullVarSpan, `@content block variables cannot be assigned a value`), + ); + + varName = namePart; + varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length)); + } else { + varSpan = new ParseSourceSpan(varStart, varStart.moveBy(varName.length)); + } + + if (!IDENTIFIER_PATTERN.test(varName)) { + errors.push( + new ParseError(varSpan, `Variable name "${varName}" must be a valid JavaScript identifier`), + ); + searchIndex = index + varName.length; + continue; + } + + if (variables.some((v) => v.name === varName)) { + errors.push( + new ParseError(varSpan, `Duplicate variable name "${varName}" in @content block`), + ); + searchIndex = index + varName.length; + continue; + } + + // @content block variables cannot be assigned an explicit value in the template + // (e.g. "let item = value"). Instead, they are assigned an argument of the calling render function + // based on their positional index. For example if we have a @content block like + // "@content(items; let item, index)" the render function for that block will be called like + // "render(items, ctx[0], ctx[1])". + variables.push(new t.Variable(varName, '', varSpan, varSpan)); + searchIndex = index + varName.length; + } + return variables; +} diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 128ab5889510..65897e5258b5 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -12,7 +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'; +import {IDENTIFIER_PATTERN, LET_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]*)/; @@ -26,9 +26,6 @@ const CONDITIONAL_ALIAS_PATTERN = /^(as\s+)(.*)/; /** Pattern used to identify an `else if` block. */ 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 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. @@ -429,7 +426,7 @@ function parseForLoopParameters( }; for (const param of secondaryParams) { - const letMatch = param.expression.match(FOR_LOOP_LET_PATTERN); + const letMatch = param.expression.match(LET_PATTERN); if (letMatch !== null) { const variablesSpan = new ParseSourceSpan( diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 15535e3b9aa1..a0deaebb6be0 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -27,6 +27,7 @@ export class Identifiers { static foreignComponent: o.ExternalReference = {name: 'ɵɵforeignComponent', moduleName: CORE}; static foreignContent: o.ExternalReference = {name: 'ɵɵforeignContent', moduleName: CORE}; + static foreignContentFn: o.ExternalReference = {name: 'ɵɵforeignContentFn', 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/util.ts b/packages/compiler/src/render3/util.ts index 9662ff0d468e..bf520a29573c 100644 --- a/packages/compiler/src/render3/util.ts +++ b/packages/compiler/src/render3/util.ts @@ -17,6 +17,9 @@ const UNSAFE_OBJECT_KEY_NAME_REGEXP = /[-.]/; /** Pattern used to validate a JavaScript identifier. */ export const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i; +/** Pattern used to identify a `let` parameter. */ +export const LET_PATTERN = /^let\s+([\S\s]*)/; + export function typeWithParameters(type: o.Expression, numParams: number): o.ExpressionType { if (numParams === 0) { return o.expressionType(type); diff --git a/packages/compiler/src/template/pipeline/ir/src/expression.ts b/packages/compiler/src/template/pipeline/ir/src/expression.ts index 06fc464530b7..3458497e1e39 100644 --- a/packages/compiler/src/template/pipeline/ir/src/expression.ts +++ b/packages/compiler/src/template/pipeline/ir/src/expression.ts @@ -14,7 +14,7 @@ import {CONTEXT_NAME} from '../../../../render3/view/util'; import {ExpressionKind, OpKind} from './enums'; import {SlotHandle} from './handle'; import {OpList, type XrefId} from './operations'; -import type {CreateOp} from './ops/create'; +import type {ConstIndex, CreateOp} from './ops/create'; import {createStatementOp} from './ops/shared'; import {Interpolation, type UpdateOp} from './ops/update'; import { @@ -161,6 +161,7 @@ export class ForeignContentExpr extends ExpressionBase { constructor( readonly childrenViewXref: XrefId, readonly childrenViewHandle: SlotHandle, + readonly foreignComponentConstIndex: ConstIndex, ) { super(); } @@ -168,7 +169,11 @@ export class ForeignContentExpr extends ExpressionBase { override visitExpression(): void {} override isEquivalent(e: o.Expression): boolean { - return e instanceof ForeignContentExpr && e.childrenViewXref === this.childrenViewXref; + return ( + e instanceof ForeignContentExpr && + e.childrenViewXref === this.childrenViewXref && + e.foreignComponentConstIndex === this.foreignComponentConstIndex + ); } override isConstant(): boolean { @@ -178,7 +183,11 @@ export class ForeignContentExpr extends ExpressionBase { override transformInternalExpressions(): void {} override clone(): ForeignContentExpr { - return new ForeignContentExpr(this.childrenViewXref, this.childrenViewHandle); + return new ForeignContentExpr( + this.childrenViewXref, + this.childrenViewHandle, + this.foreignComponentConstIndex, + ); } } 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 2af4897345ad..3e9bf39c5256 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -251,9 +251,9 @@ export interface ForeignComponentOp extends Op, ConsumesSlotOpTrait { xref: XrefId; /** - * Reference to the foreign component class/function itself as an output AST expression. + * Index of the foreign component class/function in the constant pool. */ - foreignComponentRef: o.Expression; + constIndex: ConstIndex; /** * Static attributes and property bindings. @@ -268,7 +268,7 @@ export interface ForeignComponentOp extends Op, ConsumesSlotOpTrait { */ export function createForeignComponentOp( xref: XrefId, - foreignComponentRef: o.Expression, + constIndex: ConstIndex, props: Map, sourceSpan: ParseSourceSpan | null, ): ForeignComponentOp { @@ -276,7 +276,7 @@ export function createForeignComponentOp( kind: OpKind.ForeignComponent, xref, handle: new SlotHandle(), - foreignComponentRef, + constIndex, props, sourceSpan, ...TRAIT_CONSUMES_SLOT, diff --git a/packages/compiler/src/template/pipeline/src/compilation.ts b/packages/compiler/src/template/pipeline/src/compilation.ts index 220d24b8be0c..91cb1dbb21d1 100644 --- a/packages/compiler/src/template/pipeline/src/compilation.ts +++ b/packages/compiler/src/template/pipeline/src/compilation.ts @@ -269,7 +269,7 @@ export class ViewCompilationUnit extends CompilationUnit { * Map of declared variables available within this view to the property on the context object * which they alias. */ - readonly contextVariables = new Map(); + readonly contextVariables = new Map(); /** * Set of aliases available within this view. An alias is a variable whose provided expression is diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 91d18a981768..1f31d505d82e 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -367,6 +367,14 @@ function ingestForeignComponent( for (const block of contentBlocks) { const blockView = unit.job.allocateView(unit.xref); + + // @content block variables map directly to the arguments array passed to the calling render + // function. We set the context variable's value to its index in the block's variables list + // so that code generation resolves it to its corresponding index in the arguments array. + for (let i = 0; i < block.variables.length; i++) { + blockView.contextVariables.set(block.variables[i].name, i); + } + ingestNodes(blockView, block.children); unit.create.push( @@ -392,9 +400,8 @@ function ingestForeignComponent( // 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), - ); + const constIndex = unit.job.addConst(foreignComp.component); + unit.create.push(ir.createForeignComponentOp(id, constIndex, props, element.startSourceSpan)); } /** diff --git a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts index 6b3a74b1a02c..d39b51bc1bac 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts @@ -261,7 +261,14 @@ function generateVariablesInScopeForView( for (const [name, value] of scopeView.contextVariables) { const context = new ir.ContextExpr(scope.view); // We either read the context, or, if the variable is CTX_REF, use the context directly. - const variable = value === ir.CTX_REF ? context : new o.ReadPropExpr(context, value); + let variable: o.Expression; + if (value === ir.CTX_REF) { + variable = context; + } else if (typeof value === 'number') { + variable = new o.ReadKeyExpr(context, o.literal(value)); + } else { + variable = new o.ReadPropExpr(context, value); + } // Add the variable declaration. newOps.push( ir.createVariableOp( diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index eb49fc3567eb..2f340985cb30 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -156,7 +156,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList 0; + const slot = o.literal(expr.childrenViewHandle.slot!); + return isFn + ? o + .importExpr(Identifiers.foreignContentFn) + .callFn([slot, o.literal(expr.foreignComponentConstIndex)]) + : o.importExpr(Identifiers.foreignContent).callFn([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 index ddd7d4e43dc5..e95ba5c2ee37 100644 --- a/packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts +++ b/packages/compiler/src/template/pipeline/src/phases/resolve_foreign_content.ts @@ -6,7 +6,6 @@ * 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'; @@ -48,7 +47,11 @@ export function resolveForeignContent(job: CompilationJob): void { ir.OpList.replace(op, templateOp); - const foreignContent = new ir.ForeignContentExpr(templateOp.xref, templateOp.handle); + const foreignContent = new ir.ForeignContentExpr( + templateOp.xref, + templateOp.handle, + target.constIndex, + ); target.props.set(op.propertyName, foreignContent); } } diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 0ff97f49a90d..0101bc4fc7f2 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -61,7 +61,7 @@ class R3AstHumanizer implements t.Visitor { visitContentBlock(block: t.ContentBlock) { this.result.push(['ContentBlock', block.name]); - this.visitAll([block.children]); + this.visitAll([block.variables, block.children]); } visitVariable(variable: t.Variable) { @@ -3037,13 +3037,45 @@ describe('R3 template transform', () => { it('should error if @content block has missing parameter', () => { expect(() => parse(`@content {}`)).toThrowError( - /@content block must have exactly one parameter/, + /@content block must have one or two parameters/, ); }); it('should error if @content block has multiple parameters', () => { expect(() => parse(`@content(icon, text) {}`)).toThrowError( - /@content block must have exactly one parameter/, + /@content block must have exactly one name parameter/, + ); + }); + + it('should parse a valid @content block with variables', () => { + expectFromHtml(` + @content(icon; let a, b) { + Icon content + } + `).toEqual([ + ['ContentBlock', 'icon'], + ['Variable', 'a', ''], + ['Variable', 'b', ''], + ['Element', 'span'], + ['Text', 'Icon content'], + ]); + }); + + it('should error if a variable is assigned a value', () => { + expect(() => parse(`@content(icon; let a = 123) {}`)).toThrowError( + /@content block variables cannot be assigned a value/, + ); + expect(() => parse(`@content(icon; let a, b = something) {}`)).toThrowError( + /@content block variables cannot be assigned a value/, + ); + }); + + it('should error on invalid variable name', () => { + expect(() => parse(`@content(icon; let inv-alid) {}`)).toThrowError( + /Variable name "inv-alid" must be a valid JavaScript identifier/, + ); + expect(() => parse(`@content(icon; let a, inv-alid) {}`)).toThrowError( + /Variable name "inv-alid" must be a valid JavaScript identifier/, ); }); }); diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index c770b6c3c7e7..5e8308e898d1 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -152,6 +152,7 @@ export { ɵɵelementStart, ɵɵforeignComponent, ɵɵforeignContent, + ɵɵforeignContentFn, ɵɵenableBindings, ɵɵExternalStylesFeature, ɵɵFactoryDeclaration, diff --git a/packages/core/src/interface/foreign_component.ts b/packages/core/src/interface/foreign_component.ts index 25c7dc14e84e..83d083e7773b 100644 --- a/packages/core/src/interface/foreign_component.ts +++ b/packages/core/src/interface/foreign_component.ts @@ -9,6 +9,9 @@ /** Symbol used to store and retrieve the render function for a foreign component. */ export const RENDER: unique symbol = Symbol('RENDER'); +/** Symbol used to store and retrieve the disposal registration function for a foreign component. */ +export const ON_DESTROY: unique symbol = Symbol('ON_DESTROY'); + /** * A function used to render a foreign component in an Angular template. * @@ -20,6 +23,16 @@ export const RENDER: unique symbol = Symbol('RENDER'); */ export type ForeignRenderFn = (props: TProps) => [Node[], VoidFunction?]; +/** + * A function that allows a foreign component to register a destroy callback. + * + * Angular will invoke this function during the creation phase of projected content + * to provide a cleanup callback. The foreign component is responsible for calling + * this callback when the container slot is removed or when the foreign component itself + * is destroyed. This triggers the destruction and lifecycle cleanup of the nested Angular views. + */ +export type ForeignOnDestroyFn = (destroy: VoidFunction) => void; + /** * Represents a component from another framework that Angular can import and render. * @@ -27,4 +40,5 @@ export type ForeignRenderFn = (props: TProps) => [Node[], VoidFunction?] */ export interface ForeignComponent { readonly [RENDER]: ForeignRenderFn; + readonly [ON_DESTROY]: ForeignOnDestroyFn; } diff --git a/packages/core/src/render3/foreign_import.ts b/packages/core/src/render3/foreign_import.ts index 8bfd6a28fcce..08e6cc9e4f2a 100644 --- a/packages/core/src/render3/foreign_import.ts +++ b/packages/core/src/render3/foreign_import.ts @@ -6,14 +6,27 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ForeignComponent, ForeignRenderFn, RENDER} from '../interface/foreign_component'; +import { + ForeignComponent, + ForeignRenderFn, + ForeignOnDestroyFn, + RENDER, + ON_DESTROY, +} from '../interface/foreign_component'; /** * Returns a {@link ForeignComponent} for use in Angular components. * * @template TProps The properties of the foreign component. * @param render A function that renders a foreign component. + * @param onDestroy A function for foreign content to register a destroy callback. */ -export function foreignImport(render: ForeignRenderFn): ForeignComponent { - return {[RENDER]: render}; +export function foreignImport( + render: ForeignRenderFn, + onDestroy: ForeignOnDestroyFn, +): ForeignComponent { + return { + [RENDER]: render, + [ON_DESTROY]: onDestroy, + }; } diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index ffadd96dad44..73082ece833e 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -85,6 +85,7 @@ export { ɵɵelementStart, ɵɵforeignComponent, ɵɵforeignContent, + ɵɵforeignContentFn, ɵɵgetCurrentView, ɵɵdomProperty, ɵɵinjectAttribute, diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index a5a7d9bfebc9..8d9dc8bf0785 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -6,42 +6,46 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ForeignComponent, RENDER} from '../../interface/foreign_component'; +import {ForeignComponent, RENDER, ON_DESTROY} from '../../interface/foreign_component'; import {attachPatchData} from '../context_discovery'; import {nativeInsertBefore} from '../dom_node_manipulation'; import {createForeignView} from '../foreign_view'; import {TContainerNode, TNodeType} from '../interfaces/node'; -import {HEADER_OFFSET, RENDERER, TVIEW, FLAGS} from '../interfaces/view'; -import {appendChild} from '../node_manipulation'; +import {HEADER_OFFSET, RENDERER, TVIEW, FLAGS, LViewFlags} from '../interfaces/view'; +import {appendChild, destroyLView} from '../node_manipulation'; import {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state'; import {getOrCreateTNode} from '../tnode_manipulation'; import {addToEndOfViewTree} from '../view/construction'; -import {createLContainer, addLViewToLContainer} from '../view/container'; +import {createLContainer, addLViewToLContainer, removeLViewFromLContainer} from '../view/container'; import {NodeInjector} from '../di'; import {runInInjectionContext} from '../../di'; import {Renderer} from '../interfaces/renderer'; -import {RElement, RNode} from '../interfaces/renderer_dom'; +import {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'; +import {CONTAINER_HEADER_OFFSET, LContainer, LContainerFlags} from '../interfaces/container'; +import {getConstant} from '../util/view_utils'; +import {isDestroyed} from '../interfaces/type_checks'; +import {assertNotEqual, assertNotSame} from '../../util/assert'; /** * Creation phase instruction to render a foreign component. * * @param index The index of the container in the data array. - * @param foreignComponent The matched foreign component. + * @param foreignComponentIndex The index of the matched foreign component in the constant pool. * @param props Aggregate properties and static attributes. * @codeGenApi */ export function ɵɵforeignComponent( index: number, - foreignComponent: ForeignComponent, + foreignComponentIndex: number, props?: any, ): void { const lView = getLView(); const tView = getTView(); const adjustedIndex = index + HEADER_OFFSET; + const foreignComponent = getConstant>(tView.consts, foreignComponentIndex)!; // 1. Get or create TNode for this container slot let tNode: TContainerNode; @@ -115,3 +119,57 @@ export function ɵɵforeignContent(index: number): any[] { const embeddedTView = embeddedLView[TVIEW]; return collectNativeNodes(embeddedTView, embeddedLView, embeddedTView.firstChild, []); } + +/** + * Creation phase instruction to return a function for rendering foreign content dynamically + * with arguments. + * + * @param index The index of the container in the data array. + * @param foreignComponentConstIndex The index of the matched foreign component in the constant pool. + * @codeGenApi + */ +export function ɵɵforeignContentFn( + index: number, + foreignComponentConstIndex: number, +): (...args: any[]) => 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; + const foreignComponent = getConstant>( + tView.consts, + foreignComponentConstIndex, + )!; + const onDestroy = foreignComponent[ON_DESTROY]; + + return (...args: any[]) => { + // When the function is called, instantiate and render a new embedded view inside the container. + // The arguments are passed directly as the context of the view. + const embeddedLView = createAndRenderEmbeddedLView(lView, tNode, args); + + addLViewToLContainer( + lContainer, + embeddedLView, + lContainer.length - CONTAINER_HEADER_OFFSET, + /* addToDOM */ false, + ); + + onDestroy(() => { + if (!isDestroyed(embeddedLView)) { + const embeddedLViewIndex = lContainer.indexOf(embeddedLView, CONTAINER_HEADER_OFFSET); + ngDevMode && assertNotSame(embeddedLViewIndex, -1, 'Embedded view not found in container'); + removeLViewFromLContainer(lContainer, embeddedLViewIndex - CONTAINER_HEADER_OFFSET); + } + }); + + // 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/node.ts b/packages/core/src/render3/interfaces/node.ts index 21498822e392..28a566517794 100644 --- a/packages/core/src/render3/interfaces/node.ts +++ b/packages/core/src/render3/interfaces/node.ts @@ -9,6 +9,7 @@ import {Type} from '../../interface/type'; import {KeyValueArray} from '../../util/array_utils'; import {TStylingRange} from '../interfaces/styling'; import {AttributeMarker} from './attribute_marker'; +import {ForeignComponent} from '../../interface/foreign_component'; import {TIcu} from './i18n'; import {CssSelector} from './projection'; @@ -226,8 +227,9 @@ export type TAttributes = (string | AttributeMarker | CssSelector)[]; * - Attribute arrays. * - Local definition arrays. * - Translated messages (i18n). + * - Foreign components. */ -export type TConstants = (TAttributes | string)[]; +export type TConstants = (TAttributes | string | ForeignComponent)[]; /** * Factory function that returns an array of consts. Consts can be represented as a function in diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 33fff49492b7..4441f25823cd 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -57,6 +57,7 @@ export const angularCoreEnv: {[name: string]: unknown} = (() => ({ 'ɵɵelement': r3.ɵɵelement, 'ɵɵforeignComponent': r3.ɵɵforeignComponent, 'ɵɵforeignContent': r3.ɵɵforeignContent, + 'ɵɵforeignContentFn': r3.ɵɵforeignContentFn, 'ɵɵelementContainerStart': r3.ɵɵelementContainerStart, 'ɵɵelementContainerEnd': r3.ɵɵelementContainerEnd, 'ɵɵdomElement': r3.ɵɵdomElement, diff --git a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts index ce51096766e7..c30d9aa151af 100644 --- a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -6,13 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ElementRef, signal, viewChildren} from '@angular/core'; +import {Component, ElementRef, computed, effect, 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)]); + return foreignImport( + (props) => [component(props)], + () => {}, + ); } function FancyButton(props: {children: Node[]}): Node[] { @@ -24,7 +27,7 @@ function FancyButton(props: {children: Node[]}): Node[] { } describe('foreign components', () => { - describe('content projection', () => { + describe('reactivity', () => { it('should update foreign content', async () => { @Component({ selector: 'test-cmp', @@ -53,6 +56,94 @@ describe('foreign components', () => { expect(icon.textContent).toBe('🔥'); }); + it('should update foreign content created from a callback', async () => { + function FancyIcon(props: {icon: () => Node[]}): Node[] { + const span = document.createElement('span'); + span.id = 'icon'; + for (const node of props.icon()) { + span.appendChild(node); + } + return [span]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content (icon; let _) { + {{ icon() }} + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyIcon)], + }) + class TestUpdateForeignContentCallback { + readonly icon = signal('⭐'); + } + + const fixture = TestBed.createComponent(TestUpdateForeignContentCallback); + await fixture.whenStable(); + + const icon = fixture.nativeElement.querySelector('#icon'); + expect(icon).toBeTruthy(); + expect(icon.textContent).toBe('⭐'); + + fixture.componentInstance.icon.set('🔥'); + await fixture.whenStable(); + + expect(icon.textContent).toBe('🔥'); + }); + + it('should update foreign content when the signal is passed from the callback', async () => { + function Doubler(props: { + value: () => number; + render: (count: () => number) => Node[]; + }): Node[] { + const double = computed(() => 2 * props.value()); + const div = document.createElement('div'); + for (const node of props.render(double)) { + div.appendChild(node); + } + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content (render; let double) { + {{ double() }} + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(Doubler)], + }) + class TestUpdateForeignContentCallbackSignal { + readonly value = signal(0); + } + + const fixture = TestBed.createComponent(TestUpdateForeignContentCallbackSignal); + await fixture.whenStable(); + + const value = fixture.nativeElement.querySelector('#value'); + expect(value).toBeTruthy(); + expect(value.textContent).toBe('0'); + + fixture.componentInstance.value.set(1); + await fixture.whenStable(); + + expect(value.textContent).toBe('2'); + + fixture.componentInstance.value.set(32); + await fixture.whenStable(); + + expect(value.textContent).toBe('64'); + }); + }); + + describe('content projection', () => { it('should not reparent content to next to its original container when added to the DOM', async () => { @Component({ selector: 'test-cmp', @@ -275,18 +366,18 @@ describe('foreign components', () => { expect(fixture.nativeElement.innerHTML).toBe( '' + - '' + - '' + + '' + // @content(children) (implicit) + '' + // '
' + - '' + - '' + - '' + + '' + // @content(children) (implicit) + '' + // TODO: fix after https://github.com/angular/angular/pull/69099. + '' + // '' + - '' + + '' + // '
' + - '' + + '' + //
'', ); }); @@ -329,6 +420,203 @@ describe('foreign components', () => { '', ); }); + + it('should support zero parameter callback', async () => { + function FancyList(props: {renderHeader: () => Node[]}): Node[] { + const div = document.createElement('div'); + const headerNodes = props.renderHeader(); + + for (const node of headerNodes) { + div.appendChild(node); + } + + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content(renderHeader; let _) { + Header Content + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyList)], + }) + class TestNoParamContentFn {} + + const fixture = TestBed.createComponent(TestNoParamContentFn); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + // for @content(renderHeader) + '' + + '
Header Content
' + + '' + + '', + ); + }); + + it('should support a single parameter callback', async () => { + function FancyList(props: {renderItem: (item: string) => Node[]}): Node[] { + const div = document.createElement('div'); + const itemNodes = props.renderItem('Hello Single'); + + for (const node of itemNodes) { + div.appendChild(node); + } + + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content(renderItem; let item) { + {{ item }} + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyList)], + }) + class TestOneParamContentFn {} + + const fixture = TestBed.createComponent(TestOneParamContentFn); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + // for @content(renderItem) + '' + + '
Hello Single
' + + '' + + '', + ); + }); + + it('should support multiple parameter callback', async () => { + function FancyTable(props: { + renderRow: (row: string, index: number, isLast: boolean) => Node[]; + }): Node[] { + const div = document.createElement('div'); + const nodes = props.renderRow('RowA', 0, false); + + for (const node of nodes) { + div.appendChild(node); + } + + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content(renderRow; let row, idx, isLast) { + {{ idx }} - {{ row }} (Last: {{ isLast }}) + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(FancyTable)], + }) + class TestManyParamsContentFn {} + + const fixture = TestBed.createComponent(TestManyParamsContentFn); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + // for @content(renderRow) + '' + + '
0 - RowA (Last: false)
' + + '' + + '', + ); + }); + + it('should support nested callbacks', async () => { + function OuterComp(props: {renderContent: (msg: string) => Node[]}): Node[] { + const outer = document.createElement('div'); + outer.className = 'outer'; + const nodes = props.renderContent('Outer Msg'); + for (const node of nodes) { + outer.appendChild(node); + } + return [outer]; + } + + function InnerComp(props: { + renderHeader: (innerMsg: string) => Node[]; + children: Node[]; + }): Node[] { + const inner = document.createElement('div'); + inner.className = 'inner'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'inner-header'; + const headerNodes = props.renderHeader('Inner Msg'); + for (const child of headerNodes) { + headerDiv.appendChild(child); + } + inner.appendChild(headerDiv); + + const bodyDiv = document.createElement('div'); + bodyDiv.className = 'inner-body'; + for (const child of props.children) { + bodyDiv.appendChild(child); + } + inner.appendChild(bodyDiv); + + return [inner]; + } + + @Component({ + selector: 'test-cmp', + template: ` + + @content(renderContent; let message) { + + @content(renderHeader; let innerMessage) { + {{ message }} - {{ innerMessage }} + } + Body Content + + } + + `, + // @ts-ignore + foreignImports: [frameworkImport(OuterComp), frameworkImport(InnerComp)], + }) + class TestDeepNestedContentFn {} + + const fixture = TestBed.createComponent(TestDeepNestedContentFn); + await fixture.whenStable(); + + expect(fixture.nativeElement.innerHTML).toBe( + '' + + '' + // @content(renderContent) + '' + // + '
' + + '' + // @content(renderHeader) + '' + // @content(children) (implicit) + '' + // TODO: fix after https://github.com/angular/angular/pull/69099. + '' + // + '
' + + '
Outer Msg - Inner Msg
' + + '
Body Content
' + + '
' + + '' + //
+ '
' + + '' + //
+ '', + ); + }); }); describe('queries', () => { @@ -402,4 +690,94 @@ describe('foreign components', () => { expect(clicked).toBeTrue(); }); }); + + describe('lifecycle', () => { + it('should destroy projected content when its foreign container is destroyed', async () => { + // Track the destroy callback registered by the projected content. + let destroy: VoidFunction | undefined; + function frameworkOnDestroy(callback: VoidFunction) { + destroy = callback; + } + + function frameworkImport( + component: (props: TProps) => Node[], + ): ForeignComponent { + return foreignImport((props) => [component(props)], frameworkOnDestroy); + } + + // A foreign component that acts like a conditional container (e.g. @if). + function Conditional(props: {when: () => boolean; then: () => Node[]}) { + effect((onCleanup) => { + // On cleanup (when `when()` changes or the component is destroyed), call the destroy + // callback registered by Angular for the projected content. + onCleanup(() => destroy?.()); + + if (props.when()) { + // Render the conditional content, instantiating any Angular views contained within. + // Internally this will call `frameworkOnDestroy` to register a callback to destroy the + // views when this effect's cleanup is run. + props.then(); + } + }); + + // We don't care about returning any nodes since we're just testing the content lifecycle. + return []; + } + + const ngOnDestroySpy = jasmine.createSpy(); + + @Component({ + selector: 'disposable', + template: ``, + }) + class Disposable { + ngOnDestroy() { + ngOnDestroySpy(); + } + } + + @Component({ + template: ` + + @content (then; let _) { + + } + + `, + imports: [Disposable], + // @ts-ignore + foreignImports: [frameworkImport(Conditional)], + }) + class TestDisposal { + readonly visible = signal(true); + } + + const fixture = TestBed.createComponent(TestDisposal); + await fixture.whenStable(); + + // Initially, visible is true, so the component is created and NOT destroyed. + expect(ngOnDestroySpy).not.toHaveBeenCalled(); + + // Toggle visible to false: this triggers the Conditional component's effect cleanup, + // which calls the destroy callback registered during props.then() invocation. + fixture.componentInstance.visible.set(false); + await fixture.whenStable(); + + // The projected Angular content should be destroyed. + expect(ngOnDestroySpy).toHaveBeenCalledTimes(1); + + // Toggle visible back to true: a new instance of is created. + fixture.componentInstance.visible.set(true); + await fixture.whenStable(); + + // Since the new instance is not yet destroyed, the destroy spy count remains at 1. + expect(ngOnDestroySpy).toHaveBeenCalledTimes(1); + + // Toggle visible to false again: the new instance is destroyed. + fixture.componentInstance.visible.set(false); + await fixture.whenStable(); + + expect(ngOnDestroySpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index 716b24683c8b..57d9dcec0781 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -9,6 +9,7 @@ import { ɵɵforeignComponent, ɵɵforeignContent, + ɵɵforeignContentFn, } from '../../src/render3/instructions/foreign_component'; import {foreignImport} from '../../src/render3/foreign_import'; import {destroyLView} from '../../src/render3/node_manipulation'; @@ -16,6 +17,8 @@ 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 {ɵɵadvance} from '../../src/render3/instructions/advance'; +import {ɵɵtextInterpolate2} from '../../src/render3/instructions/text_interpolation'; import {inject, InjectionToken} from '../../src/di'; import {ɵɵdefineDirective} from '../../src/render3/definition'; import {ɵɵProvidersFeature} from '../../src/render3/features/providers_feature'; @@ -27,18 +30,22 @@ describe('ɵɵforeignComponent', () => { afterEach(ViewFixture.cleanUp); it("should render a foreign component's native elements", () => { - const foreignComp = foreignImport(() => { - const el = document.createElement('div'); - el.id = 'foreign-el'; - el.textContent = 'Foreign Content'; - return [[el]]; - }); + const foreignComp = foreignImport( + () => { + const el = document.createElement('div'); + el.id = 'foreign-el'; + el.textContent = 'Foreign Content'; + return [[el]]; + }, + () => {}, + ); const fixture = new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp); + ɵɵforeignComponent(0, 0); }, }); @@ -47,16 +54,20 @@ describe('ɵɵforeignComponent', () => { it('should pass props to a foreign component', () => { let passedProps: any = null; - const foreignComp = foreignImport<{name: string}>((props) => { - passedProps = props; - return [[]]; - }); + const foreignComp = foreignImport<{name: string}>( + (props) => { + passedProps = props; + return [[]]; + }, + () => {}, + ); new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp, {name: 'Angular'}); + ɵɵforeignComponent(0, 0, {name: 'Angular'}); }, }); @@ -65,20 +76,24 @@ describe('ɵɵforeignComponent', () => { it('should call the dispose function when the containing view is destroyed', () => { let disposeCalled = false; - const foreignComp = foreignImport(() => { - return [ - [], - () => { - disposeCalled = true; - }, - ]; - }); + const foreignComp = foreignImport( + () => { + return [ + [], + () => { + disposeCalled = true; + }, + ]; + }, + () => {}, + ); const fixture = new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp); + ɵɵforeignComponent(0, 0); }, }); @@ -90,18 +105,22 @@ describe('ɵɵforeignComponent', () => { }); it('should render foreign view between sibling elements', () => { - const foreignComp = foreignImport(() => { - const el = document.createElement('div'); - el.textContent = 'Foreign Content'; - return [[el]]; - }); + const foreignComp = foreignImport( + () => { + const el = document.createElement('div'); + el.textContent = 'Foreign Content'; + return [[el]]; + }, + () => {}, + ); const fixture = new ViewFixture({ decls: 3, vars: 0, + consts: [foreignComp], create: () => { ɵɵelement(0, 'p'); - ɵɵforeignComponent(1, foreignComp); + ɵɵforeignComponent(1, 0); ɵɵelement(2, 'span'); }, }); @@ -118,18 +137,22 @@ describe('ɵɵforeignComponent', () => { }); it('should render foreign view as a child of a parent element', () => { - const foreignComp = foreignImport(() => { - const el = document.createElement('span'); - el.textContent = 'Foreign Content'; - return [[el]]; - }); + const foreignComp = foreignImport( + () => { + const el = document.createElement('span'); + el.textContent = 'Foreign Content'; + return [[el]]; + }, + () => {}, + ); const fixture = new ViewFixture({ decls: 2, vars: 0, + consts: [foreignComp], create: () => { ɵɵelementStart(0, 'div'); - ɵɵforeignComponent(1, foreignComp); + ɵɵforeignComponent(1, 0); ɵɵelementEnd(); }, }); @@ -148,13 +171,16 @@ describe('ɵɵforeignComponent', () => { it('should execute the RENDER function inside the template injection context', () => { const TEST_TOKEN = new InjectionToken('test-token'); - const foreignComp = foreignImport(() => { - const value = inject(TEST_TOKEN, {optional: true}) ?? 'null'; - const el = document.createElement('div'); - el.id = 'foreign-el'; - el.textContent = value; - return [[el]]; - }); + const foreignComp = foreignImport( + () => { + const value = inject(TEST_TOKEN, {optional: true}) ?? 'null'; + const el = document.createElement('div'); + el.id = 'foreign-el'; + el.textContent = value; + return [[el]]; + }, + () => {}, + ); class ProviderDirective { static ɵfac = () => new ProviderDirective(); @@ -168,11 +194,11 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 2, vars: 0, - consts: [['provider-dir', '']], + consts: [['provider-dir', ''], foreignComp], directives: [ProviderDirective], create: () => { ɵɵelementStart(0, 'div', 0); - ɵɵforeignComponent(1, foreignComp); + ɵɵforeignComponent(1, 1); ɵɵelementEnd(); }, }); @@ -181,13 +207,16 @@ describe('ɵɵforeignComponent', () => { }); it('should support reusing the same template between multiple view instances', () => { - const foreignComp1 = foreignImport(() => { - return [[document.createTextNode('foreign content')]]; - }); + const foreignComp1 = foreignImport( + () => { + return [[document.createTextNode('foreign content')]]; + }, + () => {}, + ); const createFn = () => { ɵɵelementStart(0, 'div'); - ɵɵforeignComponent(1, foreignComp1); + ɵɵforeignComponent(1, 0); ɵɵelementEnd(); }; const expectedHtml = @@ -200,6 +229,7 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 2, vars: 0, + consts: [foreignComp1], create: createFn, }); expect(fixture.host.innerHTML).toContain(expectedHtml); @@ -215,33 +245,36 @@ describe('ɵɵforeignComponent', () => { 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]]; - }); + }>( + (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) { @@ -270,11 +303,12 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 4, vars: 0, + consts: [foreignComp], create: () => { ɵɵdomTemplate(0, iconTemplate, 2, 0); ɵɵdomTemplate(1, descriptionTemplate, 2, 0); ɵɵdomTemplate(2, childrenTemplate, 2, 0); - ɵɵforeignComponent(3, foreignComp, { + ɵɵforeignComponent(3, 0, { icon: ɵɵforeignContent(0), description: ɵɵforeignContent(1), children: ɵɵforeignContent(2), @@ -291,6 +325,62 @@ describe('ɵɵforeignComponent', () => { '', ); }); + + it('should support passing ɵɵforeignContentFn to props', () => { + const foreignComp = foreignImport<{ + renderItem: (item: string, idx: number) => Node[]; + }>( + (props) => { + const div = document.createElement('div'); + div.id = 'container'; + + const nodes1 = props.renderItem('First', 0); + const nodes2 = props.renderItem('Second', 1); + + for (const node of nodes1) { + div.appendChild(node); + } + for (const node of nodes2) { + div.appendChild(node); + } + + return [[div]]; + }, + () => {}, + ); + + const itemTemplate = (rf: number, ctx: any) => { + if (rf & 1) { + ɵɵelementStart(0, 'span'); + ɵɵtext(1); + ɵɵelementEnd(); + } + if (rf & 2) { + const item = ctx[0]; + const index = ctx[1]; + ɵɵadvance(1); + ɵɵtextInterpolate2('#', index, ': ', item); + } + }; + + const fixture = new ViewFixture({ + decls: 2, + vars: 0, + consts: [foreignComp], + create: () => { + ɵɵdomTemplate(0, itemTemplate, 2, 2); + ɵɵforeignComponent(1, 0, { + renderItem: ɵɵforeignContentFn(0, 0), + }); + }, + }); + + fixture.update(() => {}); + + expect(fixture.host.innerHTML).toContain( + '
#0: First#1: Second
', + ); + }); }); function renderSecondInstance(fixture: ViewFixture): HTMLElement {