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({