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 d2c007039ecf..8dfb35d52f4f 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 @@ -381,7 +381,7 @@ export declare class StandaloneComponent { import { Component } from '@angular/core'; import * as i0 from "@angular/core"; export function FancyButton() { } -// @angular/core does not expose the `ForeignComponent` type this should return. +// @angular/core does not expose the `ForeignComponent` type this should return. function frameworkImport(component) { return () => { }; } @@ -389,12 +389,7 @@ export class TestCmp { title = 'Submit'; static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmp, isStandalone: true, selector: "main", ngImport: i0, template: ` - + `, isInline: true }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmp, decorators: [{ @@ -402,17 +397,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE args: [{ selector: 'main', template: ` - + `, // @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) + frameworkImport(FancyButton), ], }] }] }); @@ -449,7 +439,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE // @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) + frameworkImport(FancyButton), ], }] }] }); @@ -478,7 +468,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE // @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) + frameworkImport(FancyButton), + ], + }] + }] }); +// Nest @if to demonstrate that multiple `nextContext()` calls are correctly merged into one. +export class TestCmpConditional { + title = 'Submit'; + innerCondition = true; + outerCondition = true; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpConditional, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpConditional, isStandalone: true, selector: "main-conditional", ngImport: i0, template: ` + @if (outerCondition) { + @if (innerCondition) { + + } + } + `, isInline: true }); +} +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpConditional, decorators: [{ + type: Component, + args: [{ + selector: 'main-conditional', + template: ` + @if (outerCondition) { + @if (innerCondition) { + + } + } + `, + // @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), ], }] }] }); @@ -503,4 +525,11 @@ export declare class TestCmpRenderProps { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; } +export declare class TestCmpConditional { + title: string; + innerCondition: boolean; + outerCondition: boolean; + 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 0950f74c63c9..5a11fdb28309 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 @@ -36,6 +36,23 @@ function TestCmpRenderProps_Items_0_Template(rf, ctx) { } } +function TestCmpConditional_Conditional_0_Conditional_0_Template(rf, ctx) { + if (rf & 1) { + const ctx_r0 = i0.ɵɵnextContext(2); + i0.ɵɵforeignComponent(0, 0, { label: ctx_r0.title }); + } +} + +function TestCmpConditional_Conditional_0_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵconditionalCreate(0, TestCmpConditional_Conditional_0_Conditional_0_Template, 1, 0); + } + if (rf & 2) { + const ctx_r0 = i0.ɵɵnextContext(); + i0.ɵɵconditional(ctx_r0.innerCondition ? 0 : -1); + } +} + … export class TestCmp { @@ -95,3 +112,26 @@ export class TestCmpRenderProps { }); } +… + +export class TestCmpConditional { + // ... + static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ + type: TestCmpConditional, + selectors: [["main-conditional"]], + decls: 1, + vars: 1, + consts: [frameworkImport(FancyButton)], + template: function TestCmpConditional_Template(rf, ctx) { + if (rf & 1) { + i0.ɵɵconditionalCreate(0, TestCmpConditional_Conditional_0_Template, 1, 1); + } + if (rf & 2) { + i0.ɵɵconditional(ctx.outerCondition ? 0 : -1); + } + }, + 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 c24493233811..bfa3494a8d19 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 @@ -2,7 +2,7 @@ import {Component} from '@angular/core'; export function FancyButton() {} -// @angular/core does not expose the `ForeignComponent` type this should return. +// @angular/core does not expose the `ForeignComponent` type this should return. function frameworkImport(component: {}): Function { return () => {}; } @@ -10,17 +10,12 @@ function frameworkImport(component: {}): Function { @Component({ selector: 'main', template: ` - + `, // @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) + frameworkImport(FancyButton), ], }) export class TestCmp { @@ -43,7 +38,7 @@ export class TestCmp { // @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) + frameworkImport(FancyButton), ], }) export class TestCmpChildren { @@ -62,10 +57,31 @@ export class TestCmpChildren { // @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) + frameworkImport(FancyButton), ], }) export class TestCmpRenderProps { title = 'Submit'; } +// Nest @if to demonstrate that multiple `nextContext()` calls are correctly merged into one. +@Component({ + selector: 'main-conditional', + template: ` + @if (outerCondition) { + @if (innerCondition) { + + } + } + `, + // @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 TestCmpConditional { + title = 'Submit'; + innerCondition = true; + outerCondition = true; +} diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 1f31d505d82e..9b63a8584191 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -1961,7 +1961,10 @@ function ingestControlFlowInsertionPoint( } // Root nodes can only elements or templates with a tag name (e.g. `
`). - if (child instanceof t.Element || (child instanceof t.Template && child.tagName !== null)) { + if ( + (child instanceof t.Element && unit.job.getForeignComponent(child) === null) || + (child instanceof t.Template && child.tagName !== null) + ) { root = child; } else { return null; 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 d39b51bc1bac..d2c36e8883ba 100644 --- a/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts +++ b/packages/compiler/src/template/pipeline/src/phases/generate_variables.ts @@ -73,6 +73,7 @@ function recursivelyProcessView(view: ViewCompilationUnit, parentScope: Scope | } } + view.create.prepend(generateVariablesInScopeForView(view, scope, false)); view.update.prepend(generateVariablesInScopeForView(view, scope, false)); for (const expr of view.functions) { @@ -235,12 +236,12 @@ function getScopeForView(view: ViewCompilationUnit, parent: Scope | null): Scope * This is a recursive process, as views inherit variables available from their parent view, which * itself may have inherited variables, etc. */ -function generateVariablesInScopeForView( +function generateVariablesInScopeForView>( view: ViewCompilationUnit, scope: Scope, isCallback: boolean, -): ir.VariableOp[] { - const newOps: ir.VariableOp[] = []; +): ir.VariableOp[] { + const newOps: ir.VariableOp[] = []; if (scope.view !== view.xref) { // Before generating variables for a parent view, we need to switch to the context of the parent @@ -306,7 +307,7 @@ function generateVariablesInScopeForView( if (scope.view !== view.xref || isCallback) { for (const decl of scope.letDeclarations) { newOps.push( - ir.createVariableOp( + ir.createVariableOp( view.job.allocateXrefId(), decl.variable, new ir.ContextLetReferenceExpr(decl.targetId, decl.targetSlot), @@ -318,7 +319,7 @@ function generateVariablesInScopeForView( if (scope.parent !== null) { // Recursively add variables from the parent scope. - newOps.push(...generateVariablesInScopeForView(view, scope.parent, false)); + newOps.push(...generateVariablesInScopeForView(view, scope.parent, false)); } return newOps; } diff --git a/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts b/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts index c5c65c5bda42..2a1b72dff263 100644 --- a/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts +++ b/packages/compiler/src/template/pipeline/src/phases/next_context_merging.ts @@ -39,11 +39,12 @@ export function mergeNextContextExpressions(job: CompilationJob): void { mergeNextContextsInOps(op.handlerOps); } } + mergeNextContextsInOps(unit.create); mergeNextContextsInOps(unit.update); } } -function mergeNextContextsInOps(ops: ir.OpList): void { +function mergeNextContextsInOps(ops: ir.OpList): void { for (const op of ops) { // Look for a candidate operation to maybe merge. if ( @@ -82,7 +83,7 @@ function mergeNextContextsInOps(ops: ir.OpList): void { case ir.ExpressionKind.NextContext: // Merge the previous `ir.NextContextExpr` into this one. expr.steps += mergeSteps; - ir.OpList.remove(op as ir.UpdateOp); + ir.OpList.remove(op as ir.CreateOp | ir.UpdateOp); tryToMerge = false; break; case ir.ExpressionKind.GetCurrentView: diff --git a/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts b/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts index d8e71e7bff31..74d48aee0d15 100644 --- a/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts +++ b/packages/compiler/src/template/pipeline/src/phases/variable_optimization.ts @@ -69,7 +69,7 @@ export function optimizeVariables(job: CompilationJob): void { } } - // Note that we skip over arrow function operations, because they are considered + // Note that we skip over nested scopes, because they are considered // separate boundaries that should not influence the surrounding create/update // operations. This is a side-effect of not being able to control which nested // ops `visitExpressionsInOp` will visit. Without this logic, variable references @@ -383,7 +383,7 @@ function collectOpInfo( if (expr.kind === ir.ExpressionKind.ReadVariable) { variablesUsed.add(expr.xref); - } else { + } else if (!(flags & ir.VisitorContextFlag.InChildOperation)) { fences |= fencesForIrExpression(expr); } }); 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 acf29dad36f9..b2be0e26f9cb 100644 --- a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -630,6 +630,282 @@ describe('foreign components', () => { }); }); + describe('control flow', () => { + it('should support foreign component with bindings @if', async () => { + function LabeledButton(props: {label: () => string}): Node[] { + const button = document.createElement('button'); + button.textContent = props.label(); + return [button]; + } + + @Component({ + selector: 'test-cmp', + template: ` + @if (show()) { + + } + `, + // @ts-ignore + foreignImports: [frameworkImport(LabeledButton)], + }) + class TestForeignInIf { + readonly show = signal(true); + readonly buttonText = signal('Submit'); + } + + const fixture = TestBed.createComponent(TestForeignInIf); + await fixture.whenStable(); + + let button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toBe('Submit'); + + fixture.componentInstance.show.set(false); + await fixture.whenStable(); + + button = fixture.nativeElement.querySelector('button'); + expect(button).toBeFalsy(); + + fixture.componentInstance.show.set(true); + await fixture.whenStable(); + + button = fixture.nativeElement.querySelector('button'); + expect(button).toBeTruthy(); + expect(button.textContent).toBe('Submit'); + }); + + it('should support foreign components in @if, @else if, and @else blocks', async () => { + function StatusBadge(props: {text: () => string}): Node[] { + const badge = document.createElement('span'); + badge.textContent = props.text(); + return [badge]; + } + + @Component({ + selector: 'test-cmp', + template: ` + @if (status() === 'success') { + + } @else if (status() === 'warning') { + + } @else { + + } + `, + // @ts-ignore + foreignImports: [frameworkImport(StatusBadge)], + }) + class TestForeignInIfElse { + readonly status = signal('success'); + readonly successText = signal('Success'); + readonly warningText = signal('Warning'); + readonly errorText = signal('Error'); + } + + const fixture = TestBed.createComponent(TestForeignInIfElse); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Success'); + + fixture.componentInstance.status.set('warning'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Warning'); + + fixture.componentInstance.status.set('other'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Error'); + + fixture.componentInstance.status.set('success'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Success'); + }); + + it('should support foreign components in @for blocks', async () => { + function ItemCard(props: {title: string}): Node[] { + const div = document.createElement('div'); + div.className = 'item-card'; + div.textContent = props.title; + return [div]; + } + + @Component({ + selector: 'test-cmp', + template: ` + @for (item of items(); track item.id) { + + } @empty { + + } + `, + // @ts-ignore + foreignImports: [frameworkImport(ItemCard)], + }) + class TestForeignInFor { + readonly items = signal([ + {id: 1, name: 'First'}, + {id: 2, name: 'Second'}, + ]); + readonly emptyText = 'No Items'; + } + + const fixture = TestBed.createComponent(TestForeignInFor); + await fixture.whenStable(); + + let cards = fixture.nativeElement.querySelectorAll('.item-card'); + expect(cards.length).toBe(2); + expect(cards[0].textContent).toBe('First'); + expect(cards[1].textContent).toBe('Second'); + + // Reorder existing and add a new item + fixture.componentInstance.items.set([ + {id: 2, name: 'Second'}, + {id: 1, name: 'First'}, + {id: 3, name: 'Third'}, + ]); + await fixture.whenStable(); + + cards = fixture.nativeElement.querySelectorAll('.item-card'); + expect(cards.length).toBe(3); + expect(cards[0].textContent).toBe('Second'); + expect(cards[1].textContent).toBe('First'); + expect(cards[2].textContent).toBe('Third'); + + // Remove an item + fixture.componentInstance.items.set([ + {id: 3, name: 'Third'}, + {id: 1, name: 'First'}, + ]); + await fixture.whenStable(); + + cards = fixture.nativeElement.querySelectorAll('.item-card'); + expect(cards.length).toBe(2); + expect(cards[0].textContent).toBe('Third'); + expect(cards[1].textContent).toBe('First'); + + // Remove all items (trigger @empty block) + fixture.componentInstance.items.set([]); + await fixture.whenStable(); + + cards = fixture.nativeElement.querySelectorAll('.item-card'); + expect(cards.length).toBe(1); + expect(cards[0].textContent).toBe('No Items'); + }); + + it('should support foreign components in @switch blocks', async () => { + function RoleBadge(props: {role: string}): Node[] { + const span = document.createElement('span'); + span.textContent = props.role; + return [span]; + } + + @Component({ + selector: 'test-cmp', + template: ` + @switch (role()) { + @case ('admin') { + + } + @case ('editor') { + + } + @default { + + } + } + `, + // @ts-ignore + foreignImports: [frameworkImport(RoleBadge)], + }) + class TestForeignInSwitch { + readonly role = signal('admin'); + } + + const fixture = TestBed.createComponent(TestForeignInSwitch); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Admin'); + + fixture.componentInstance.role.set('editor'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Editor'); + + fixture.componentInstance.role.set('viewer'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Guest'); + + fixture.componentInstance.role.set('admin'); + await fixture.whenStable(); + + expect(fixture.nativeElement.textContent).toBe('Admin'); + }); + + it('should support nested control flow with foreign components', async () => { + function UserTag(props: {name: string}): Node[] { + const span = document.createElement('span'); + span.className = 'user-tag'; + span.textContent = props.name; + return [span]; + } + + @Component({ + selector: 'test-cmp', + template: ` + @if (sectionVisible()) { + @for (user of users; track user.id) { + @if (user.active()) { + + } + } + } + `, + // @ts-ignore + foreignImports: [frameworkImport(UserTag)], + }) + class TestNestedControlFlow { + readonly sectionVisible = signal(true); + readonly users = [ + {id: 1, name: 'Alice', active: signal(true)}, + {id: 2, name: 'Bob', active: signal(false)}, + {id: 3, name: 'Charlie', active: signal(true)}, + ]; + } + + const fixture = TestBed.createComponent(TestNestedControlFlow); + await fixture.whenStable(); + + let tags = fixture.nativeElement.querySelectorAll('.user-tag'); + expect(tags.length).toBe(2); + expect(tags[0].textContent).toBe('Alice'); + expect(tags[1].textContent).toBe('Charlie'); + + fixture.componentInstance.users[1].active.set(true); + await fixture.whenStable(); + + tags = fixture.nativeElement.querySelectorAll('.user-tag'); + expect(tags.length).toBe(3); + expect(tags[0].textContent).toBe('Alice'); + expect(tags[1].textContent).toBe('Bob'); + expect(tags[2].textContent).toBe('Charlie'); + + fixture.componentInstance.sectionVisible.set(false); + await fixture.whenStable(); + + tags = fixture.nativeElement.querySelectorAll('.user-tag'); + expect(tags.length).toBe(0); + + fixture.componentInstance.sectionVisible.set(true); + await fixture.whenStable(); + + tags = fixture.nativeElement.querySelectorAll('.user-tag'); + expect(tags.length).toBe(3); + }); + }); + describe('queries', () => { it('should support querying elements inside projected foreign content', async () => { @Component({