From 8e3f50c881aba91351b9bace790495af8954cb4d Mon Sep 17 00:00:00 2001 From: leonsenft Date: Mon, 1 Jun 2026 15:01:21 -0700 Subject: [PATCH 1/3] refactor(compiler): support passing `@content` blocks as functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, foreign component `@content` blocks were rendered eagerly by Angular and could only project a list of nodes. With this change, `@content` can be used to declare a function (e.g. `@content(renderItem; let item)`) that is passed as a callback prop to the foreign component, allowing the foreign component to invoke it with context arguments at its leisure. Implementation details: - Introduces a new runtime instruction `ɵɵforeignContentFn` which wraps the template function so it can be called on demand with arguments by the foreign component. - Extends the compiler AST to parse and validate `@content` parameters. - Maps `@content` parameters to the corresponding positional arguments of the calling foreign component function property. --- .../standalone/GOLDEN_PARTIAL.js | 34 ++ .../standalone/foreign_component.js | 34 ++ .../standalone/foreign_component.local.js | 33 ++ .../standalone/foreign_component.ts | 19 ++ packages/compiler/src/render3/r3_ast.ts | 1 + .../compiler/src/render3/r3_content_blocks.ts | 112 ++++++- .../compiler/src/render3/r3_control_flow.ts | 7 +- .../compiler/src/render3/r3_identifiers.ts | 1 + packages/compiler/src/render3/util.ts | 3 + .../src/template/pipeline/src/compilation.ts | 2 +- .../src/template/pipeline/src/ingest.ts | 8 + .../pipeline/src/phases/generate_variables.ts | 9 +- .../src/template/pipeline/src/phases/reify.ts | 9 +- .../render3/r3_template_transform_spec.ts | 38 ++- .../core/src/core_render3_private_export.ts | 1 + packages/core/src/render3/index.ts | 1 + .../render3/instructions/foreign_component.ts | 40 ++- packages/core/src/render3/jit/environment.ts | 1 + .../foreign_component_spec.ts | 301 +++++++++++++++++- .../test/render3/foreign_component_spec.ts | 55 ++++ 20 files changed, 670 insertions(+), 39 deletions(-) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js index 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..c3f9e2947545 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 { @@ -58,3 +72,23 @@ export class TestCmpChildren { encapsulation: 2 }); } + +… + +export class TestCmpRenderProps { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpRenderProps, + selectors: [["main-render-props"]], + decls: 2, + vars: 0, + template: function TestCmpRenderProps_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵdomTemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2); + i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(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..4db261a58897 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 { @@ -58,3 +72,22 @@ export class TestCmpChildren { encapsulation: 2 }); } + +… + +export class TestCmpRenderProps { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpRenderProps, + selectors: [["main-render-props"]], + decls: 2, + vars: 0, + template: function TestCmpRenderProps_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵtemplate(0, TestCmpRenderProps_Items_0_Template, 2, 2); + i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(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/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..112a715ca619 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( 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..bd898785c545 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -800,9 +800,12 @@ function reifyIrExpression(unit: CompilationUnit, expr: o.Expression): o.Express 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!)]); + if (!(unit instanceof ViewCompilationUnit)) { + throw new Error(`AssertionError: must be compiling a component`); + } + const isFn = unit.job.views.get(expr.childrenViewXref)!.contextVariables.size > 0; + const identifier = isFn ? Identifiers.foreignContentFn : Identifiers.foreignContent; + return o.importExpr(identifier).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/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/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..b6c1b2b4ee0e 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -20,11 +20,11 @@ import {createLContainer, addLViewToLContainer} 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'; /** * Creation phase instruction to render a foreign component. @@ -115,3 +115,39 @@ 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. + * @codeGenApi + */ +export function ɵɵforeignContentFn(index: 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; + + 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, + ); + + // Extract and return the root nodes of the created view + const embeddedTView = embeddedLView[TVIEW]; + return collectNativeNodes(embeddedTView, embeddedLView, embeddedTView.firstChild, []); + }; +} diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 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..cdd260005daa 100644 --- a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ElementRef, signal, viewChildren} from '@angular/core'; +import {Component, ElementRef, computed, 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'; @@ -23,8 +23,31 @@ function FancyButton(props: {children: Node[]}): Node[] { return [button]; } +// TODO: support this inline. +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]; +} + describe('foreign components', () => { - describe('content projection', () => { + describe('reactivity', () => { it('should update foreign content', async () => { @Component({ selector: 'test-cmp', @@ -53,6 +76,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 +386,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 +440,178 @@ 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]; + } + + @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', () => { diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index 716b24683c8b..5761d4a178bc 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'; @@ -291,6 +294,58 @@ 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, + create: () => { + ɵɵdomTemplate(0, itemTemplate, 2, 2); + ɵɵforeignComponent(1, foreignComp, { + renderItem: ɵɵforeignContentFn(0), + }); + }, + }); + + fixture.update(() => {}); + + expect(fixture.host.innerHTML).toContain( + '
#0: First#1: Second
', + ); + }); }); function renderSecondInstance(fixture: ViewFixture): HTMLElement { From 7befc0cd4ac8fdda22b48e289186e071fe0023fc Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 2 Jun 2026 16:55:25 -0700 Subject: [PATCH 2/3] fix(compiler): support foreign components defined outside top-level scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, the template pipeline directly emits the raw expression for foreign component definitions (such as `frameworkImport(MyComponent)`) directly into the body of the generated template function. If a foreign component is defined inside a local scope or is non-exported (e.g. nested inside a test block), the emitted template function may not have access to that variable because `ɵɵdefineComponent` and its template functions are emitted at the top-level module scope. This previously caused reference errors during template compilation. This commit updates the compilation pipeline to instead ingest foreign component references into the component's `consts` pool. The `ɵɵforeignComponent` runtime instruction is updated to accept an index into the constant pool rather than a raw expression. By routing the references through the `consts` pool, block-scoped classes and variables are appropriately captured by `ngtsc` without scoping errors, properly supporting nested/local foreign component usage. --- .../standalone/foreign_component.js | 9 ++-- .../standalone/foreign_component.local.js | 9 ++-- .../src/template/pipeline/src/ingest.ts | 3 +- .../render3/instructions/foreign_component.ts | 6 ++- packages/core/src/render3/interfaces/node.ts | 4 +- .../foreign_component_spec.ts | 48 ++++++++++--------- .../test/render3/foreign_component_spec.ts | 28 +++++++---- 7 files changed, 64 insertions(+), 43 deletions(-) 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 c3f9e2947545..d4e447e56954 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 @@ -45,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 @@ -63,10 +64,11 @@ 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 @@ -82,10 +84,11 @@ export class 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, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(0) }); + i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(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 4db261a58897..611a7b324848 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 @@ -45,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 @@ -63,10 +64,11 @@ 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 @@ -82,10 +84,11 @@ export class 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, frameworkImport(FancyButton), { label: ctx.title, items: i0.ɵɵforeignContentFn(0) }); + i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0) }); } }, encapsulation: 2 diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 112a715ca619..09f07e001aea 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -400,8 +400,9 @@ 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. + const constIndex = unit.job.addConst(foreignComp.component); unit.create.push( - ir.createForeignComponentOp(id, foreignComp.component, props, element.startSourceSpan), + ir.createForeignComponentOp(id, o.literal(constIndex), props, element.startSourceSpan), ); } diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index b6c1b2b4ee0e..e8f9fb78cbf8 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -25,23 +25,25 @@ import {createAndRenderEmbeddedLView} from '../view_manipulation'; import {collectNativeNodes} from '../collect_native_nodes'; import {assertLContainer} from '../assert'; import {CONTAINER_HEADER_OFFSET, LContainer, LContainerFlags} from '../interfaces/container'; +import {getConstant} from '../util/view_utils'; /** * 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; 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/test/acceptance/foreign_component/foreign_component_spec.ts b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts index cdd260005daa..e8a7d8151107 100644 --- a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -23,29 +23,6 @@ function FancyButton(props: {children: Node[]}): Node[] { return [button]; } -// TODO: support this inline. -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]; -} - describe('foreign components', () => { describe('reactivity', () => { it('should update foreign content', async () => { @@ -571,6 +548,31 @@ describe('foreign components', () => { 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: ` diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index 5761d4a178bc..34388098224b 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -40,8 +40,9 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp); + ɵɵforeignComponent(0, 0); }, }); @@ -58,8 +59,9 @@ describe('ɵɵforeignComponent', () => { new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp, {name: 'Angular'}); + ɵɵforeignComponent(0, 0, {name: 'Angular'}); }, }); @@ -80,8 +82,9 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 1, vars: 0, + consts: [foreignComp], create: () => { - ɵɵforeignComponent(0, foreignComp); + ɵɵforeignComponent(0, 0); }, }); @@ -102,9 +105,10 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 3, vars: 0, + consts: [foreignComp], create: () => { ɵɵelement(0, 'p'); - ɵɵforeignComponent(1, foreignComp); + ɵɵforeignComponent(1, 0); ɵɵelement(2, 'span'); }, }); @@ -130,9 +134,10 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 2, vars: 0, + consts: [foreignComp], create: () => { ɵɵelementStart(0, 'div'); - ɵɵforeignComponent(1, foreignComp); + ɵɵforeignComponent(1, 0); ɵɵelementEnd(); }, }); @@ -171,11 +176,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(); }, }); @@ -190,7 +195,7 @@ describe('ɵɵforeignComponent', () => { const createFn = () => { ɵɵelementStart(0, 'div'); - ɵɵforeignComponent(1, foreignComp1); + ɵɵforeignComponent(1, 0); ɵɵelementEnd(); }; const expectedHtml = @@ -203,6 +208,7 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 2, vars: 0, + consts: [foreignComp1], create: createFn, }); expect(fixture.host.innerHTML).toContain(expectedHtml); @@ -273,11 +279,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), @@ -332,9 +339,10 @@ describe('ɵɵforeignComponent', () => { const fixture = new ViewFixture({ decls: 2, vars: 0, + consts: [foreignComp], create: () => { ɵɵdomTemplate(0, itemTemplate, 2, 2); - ɵɵforeignComponent(1, foreignComp, { + ɵɵforeignComponent(1, 0, { renderItem: ɵɵforeignContentFn(0), }); }, From ebe6b5e400199b6422d9805832b950faea138ebb Mon Sep 17 00:00:00 2001 From: leonsenft Date: Sat, 6 Jun 2026 16:28:38 -0700 Subject: [PATCH 3/3] fix(core): introduce disposal mechanism for Angular views in foreign `@content` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinate template lifecycle events between Angular and foreign components to allow clean teardown of nested Angular views inside a foreign container. Previously, when Angular content was projected into a foreign component (for instance, via render props), Angular had no way to receive destruction notifications from the foreign component. If the foreign component unmounted or conditionally removed its children, the nested Angular views remained active, leading to memory leaks and incomplete lifecycle teardowns. This change introduces the `ON_DESTROY` symbol and a new registration mechanism (`ForeignOnDestroyFn`) on the `ForeignComponent` interface. The `foreignImport` helper now takes an additional `onDestroy` callback function where the foreign component can register to receive Angular's view-destruction callback. During the creation phase, `ɵɵforeignContentFn` resolves the foreign component from the constant pool using a new constant pool index and invokes the `onDestroy` function. This registers a callback that destroys the corresponding embedded view from the container. In the compiler, `ForeignComponentOp` is modified to track the target constant pool index, and `ForeignContentExpr` reification is updated to pass this index to `ɵɵforeignContentFn`. --- .../standalone/foreign_component.js | 2 +- .../standalone/foreign_component.local.js | 2 +- .../template/pipeline/ir/src/expression.ts | 15 +- .../template/pipeline/ir/src/ops/create.ts | 8 +- .../src/template/pipeline/src/ingest.ts | 4 +- .../src/template/pipeline/src/phases/reify.ts | 10 +- .../src/phases/resolve_foreign_content.ts | 7 +- .../core/src/interface/foreign_component.ts | 14 ++ packages/core/src/render3/foreign_import.ts | 19 +- .../render3/instructions/foreign_component.ts | 30 ++- .../foreign_component_spec.ts | 97 ++++++++- .../test/render3/foreign_component_spec.ts | 191 ++++++++++-------- 12 files changed, 290 insertions(+), 109 deletions(-) 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 d4e447e56954..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 @@ -88,7 +88,7 @@ export class TestCmpRenderProps { 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) }); + 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 611a7b324848..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 @@ -88,7 +88,7 @@ export class TestCmpRenderProps { 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) }); + i0.ɵɵforeignComponent(1, 0, { label: ctx.title, items: i0.ɵɵforeignContentFn(0, 0) }); } }, encapsulation: 2 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/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 09f07e001aea..1f31d505d82e 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -401,9 +401,7 @@ function ingestForeignComponent( // through directly passed signal properties, alleviating the need for any explicit update // operations. const constIndex = unit.job.addConst(foreignComp.component); - unit.create.push( - ir.createForeignComponentOp(id, o.literal(constIndex), props, element.startSourceSpan), - ); + unit.create.push(ir.createForeignComponentOp(id, constIndex, props, element.startSourceSpan)); } /** diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index bd898785c545..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 identifier = isFn ? Identifiers.foreignContentFn : Identifiers.foreignContent; - return o.importExpr(identifier).callFn([o.literal(expr.childrenViewHandle.slot!)]); + 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/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/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index e8f9fb78cbf8..8d9dc8bf0785 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -6,17 +6,17 @@ * 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'; @@ -26,6 +26,8 @@ import {collectNativeNodes} from '../collect_native_nodes'; import {assertLContainer} from '../assert'; 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. @@ -123,9 +125,13 @@ export function ɵɵforeignContent(index: number): any[] { * 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): (...args: any[]) => any[] { +export function ɵɵforeignContentFn( + index: number, + foreignComponentConstIndex: number, +): (...args: any[]) => any[] { const lView = getLView(); const adjustedIndex = index + HEADER_OFFSET; @@ -136,11 +142,17 @@ export function ɵɵforeignContentFn(index: number): (...args: any[]) => any[] { 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, @@ -148,6 +160,14 @@ export function ɵɵforeignContentFn(index: number): (...args: any[]) => any[] { /* 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/test/acceptance/foreign_component/foreign_component_spec.ts b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts index e8a7d8151107..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, computed, 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[] { @@ -687,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 34388098224b..57d9dcec0781 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -30,12 +30,15 @@ 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, @@ -51,10 +54,13 @@ 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, @@ -70,14 +76,17 @@ 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, @@ -96,11 +105,14 @@ 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, @@ -125,11 +137,14 @@ 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, @@ -156,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(); @@ -189,9 +207,12 @@ 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'); @@ -224,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) { @@ -305,22 +329,25 @@ 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]]; - }); + }>( + (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) { @@ -343,7 +370,7 @@ describe('ɵɵforeignComponent', () => { create: () => { ɵɵdomTemplate(0, itemTemplate, 2, 2); ɵɵforeignComponent(1, 0, { - renderItem: ɵɵforeignContentFn(0), + renderItem: ɵɵforeignContentFn(0, 0), }); }, });