refactor(core): defer foreign component rendering to post-update pass

Foreign components imported via foreignImports and created by the
ɵɵforeignComponent instruction were previously rendered eagerly in the
creation phase (rf & 1) of the template. This restricted which
properties could be passed to foreign component props, as parent-bound
inputs (@Input(), input(), input.required()), properties initialized in
ngOnInit(), and pull-based view queries (viewChild()) were not yet
initialized at creation time.

This change defers foreign component rendering to run as a view effect
during the update pass:
- Update ɵɵforeignComponent in core to schedule component rendering via
  createViewEffect (executed in runEffectsInView during refreshView),
  executed with setActiveConsumer(null) to prevent reactive context
  leakage and destroyed immediately upon first execution.
- Update ɵɵforeignComponent to strictly accept props as a factory function
  (() => props) or null.
- Update the compiler template pipeline to wrap foreign component props
  in an arrow function closure (() => ({ ... })).
- Hoist creation-time foreign content projection instructions
  (ɵɵforeignContent, ɵɵforeignContentFn) into creation-phase variable
  declarations before ɵɵforeignComponent so creation-time context is
  captured safely.

(cherry picked from commit 34817da735)
This commit is contained in:
leonsenft
2026-08-25 21:08:41 +00:00
committed by Matthew Beck
parent bee058eef8
commit 60f8a785fd
8 changed files with 403 additions and 46 deletions
@@ -39,7 +39,7 @@ 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 });
i0.ɵɵforeignComponent(0, 0, () => ({ label: ctx_r0.title }));
}
}
@@ -65,7 +65,7 @@ export class TestCmp {
consts: [frameworkImport(FancyButton)],
template: function TestCmp_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵforeignComponent(0, 0, { 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
@@ -85,7 +85,10 @@ export class TestCmpChildren {
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, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0, 0), description: i0.ɵɵforeignContent(1, 0), children: i0.ɵɵforeignContent(2, 0) });
const icon_r1 = i0.ɵɵforeignContent(0, 0);
const description_r2 = i0.ɵɵforeignContent(1, 0);
const children_r3 = i0.ɵɵforeignContent(2, 0);
i0.ɵɵforeignComponent(3, 0, () => ({ label: ctx.title, icon: icon_r1, description: description_r2, children: children_r3 }));
}
},
encapsulation: 2
@@ -105,7 +108,8 @@ 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, 0) });
const items_r3 = i0.ɵɵforeignContentFn(0, 0);
i0.ɵɵforeignComponent(1, 0, () => ({ label: ctx.title, items: items_r3 }));
}
},
encapsulation: 2
@@ -48,7 +48,7 @@ export class TestCmp {
consts: [frameworkImport(FancyButton)],
template: function TestCmp_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵforeignComponent(0, 0, { 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
@@ -68,7 +68,10 @@ export class TestCmpChildren {
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, 0, { label: ctx.title, icon: i0.ɵɵforeignContent(0, 0), description: i0.ɵɵforeignContent(1, 0), children: i0.ɵɵforeignContent(2, 0) });
const icon_r1 = i0.ɵɵforeignContent(0, 0);
const description_r2 = i0.ɵɵforeignContent(1, 0);
const children_r3 = i0.ɵɵforeignContent(2, 0);
i0.ɵɵforeignComponent(3, 0, () => ({ label: ctx.title, icon: icon_r1, description: description_r2, children: children_r3 }));
}
},
encapsulation: 2
@@ -88,7 +91,8 @@ 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, 0) });
const items_r3 = i0.ɵɵforeignContentFn(0, 0);
i0.ɵɵforeignComponent(1, 0, () => ({ label: ctx.title, items: items_r3 }));
}
},
encapsulation: 2
@@ -144,7 +144,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList<ir.CreateOp
);
break;
case ir.OpKind.ForeignComponent:
const propsExpr =
const propsMap =
op.props.size > 0
? o.literalMap(
Array.from(op.props.entries()).map(([key, value]) => ({
@@ -154,6 +154,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList<ir.CreateOp
})),
)
: null;
const propsExpr = propsMap !== null ? o.arrowFn([], propsMap) : null;
ir.OpList.replace(
op,
ng.foreignComponent(op.handle.slot!, o.literal(op.constIndex), propsExpr, op.sourceSpan),
@@ -52,7 +52,23 @@ export function resolveForeignContent(job: CompilationJob): void {
templateOp.handle,
target.constIndex,
);
target.props.set(op.propertyName, foreignContent);
const varXref = job.allocateXrefId();
const variable: ir.SemanticVariable = {
kind: ir.SemanticVariableKind.Identifier,
name: null,
identifier: op.propertyName,
local: true,
};
const varOp = ir.createVariableOp<ir.CreateOp>(
varXref,
variable,
foreignContent,
ir.VariableFlags.None,
);
ir.OpList.insertBefore<ir.CreateOp>(varOp, target);
target.props.set(op.propertyName, new ir.ReadVariableExpr(varXref));
}
}
}
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {setActiveConsumer} from '../../../primitives/signals';
import {Injector} from '../../di/injector';
import {InternalInjectFlags} from '../../di/interface/injector';
import {
@@ -28,8 +29,9 @@ import {TContainerNode, TNodeType} from '../interfaces/node';
import {Renderer} from '../interfaces/renderer';
import {RNode} from '../interfaces/renderer_dom';
import {isDestroyed} from '../interfaces/type_checks';
import {FLAGS, HEADER_OFFSET, LView, RENDERER, TVIEW} from '../interfaces/view';
import {ENVIRONMENT, FLAGS, HEADER_OFFSET, LView, RENDERER, TVIEW} from '../interfaces/view';
import {appendChild} from '../node_manipulation';
import {createViewEffect} from '../reactivity/effect';
import {getLView, getTView, setCurrentTNode, setCurrentTNodeAsNotParent} from '../state';
import {getOrCreateTNode} from '../tnode_manipulation';
import {getConstant} from '../util/view_utils';
@@ -42,13 +44,13 @@ import {createAndRenderEmbeddedLView} from '../view_manipulation';
*
* @param index The index of the container in the data array.
* @param foreignComponentIndex The index of the matched foreign component in the constant pool.
* @param props Aggregate properties and static attributes.
* @param props A factory function returning aggregate properties/static attributes.
* @codeGenApi
*/
export function ɵɵforeignComponent(
index: number,
foreignComponentIndex: number,
props?: any,
props?: (() => any) | null,
): void {
const lView = getLView();
const tView = getTView();
@@ -83,30 +85,41 @@ export function ɵɵforeignComponent(
// 4. Create the Foreign View and insert it at index 0 of the container
const viewRef = createForeignView(lContainer, 0);
// 5. Resolve context and call the RENDER function to get the nodes and DisposeFn
// Context is optional because foreign components may not require context or a FOREIGN_CONTEXT
// provider might not be configured in the component/element injector hierarchy.
const context = getOrCreateInjectable(
tNode,
lView,
FOREIGN_CONTEXT,
InternalInjectFlags.Optional,
);
const [nodes, dispose] = foreignComponent[RENDER](props, context ?? undefined);
// 5. Create a run-once view effect to render the foreign component during runEffectsInView
const node = createViewEffect(lView, lView[ENVIRONMENT].changeDetectionScheduler!, () => {
// Destroy this effect the first time it's called to ensure it doesn't run more than once.
node.destroy();
// 6. Insert the returned nodes into the foreign view, between its head and tail comment anchors.
const tail = viewRef.tail as RNode;
const parent = tail.parentNode;
if (parent) {
for (let i = 0; i < nodes.length; i++) {
nativeInsertBefore(renderer, parent, nodes[i], tail, false);
if (isDestroyed(lView)) {
return;
}
}
// 7. Register the DisposeFn in the foreign view's LView destroy hooks.
if (dispose) {
viewRef.onDestroy(dispose);
}
const prevConsumer = setActiveConsumer(null);
try {
const resolvedProps = props ? props() : undefined;
const context = getOrCreateInjectable(
tNode,
lView,
FOREIGN_CONTEXT,
InternalInjectFlags.Optional,
);
const [nodes, dispose] = foreignComponent[RENDER](resolvedProps, context ?? undefined);
const tail = viewRef.tail as RNode;
const parent = tail.parentNode;
if (parent) {
for (let i = 0; i < nodes.length; i++) {
nativeInsertBefore(renderer, parent, nodes[i], tail, false);
}
}
if (dispose) {
viewRef.onDestroy(dispose);
}
} finally {
setActiveConsumer(prevConsumer);
}
});
}
/**
@@ -188,8 +201,8 @@ export function ɵɵforeignContent(index: number, foreignComponentConstIndex: nu
}
});
// Extract and return the root nodes of the created view
const embeddedTView = embeddedLView[TVIEW];
// Extract and return the root nodes of the created view
return collectNativeNodes(embeddedTView, embeddedLView, embeddedTView.firstChild, []);
};
@@ -7,14 +7,20 @@
*/
import {
AfterViewInit,
Component,
ElementRef,
Injector,
Input,
OnInit,
ViewChild,
computed,
effect,
inject,
input,
signal,
untracked,
viewChild,
viewChildren,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
@@ -1210,4 +1216,278 @@ describe('foreign components', () => {
expect(fixture.nativeElement.textContent).toBe('true');
});
});
// Verify deferred rendering and property initialization timing
describe('deferred rendering and property initialization', () => {
function TitleWidget(props: {title: string}): Node[] {
const span = document.createElement('span');
span.id = 'title-display';
span.textContent = props.title ?? 'UNDEFINED';
return [span];
}
function QueryWidget(props: {target: any}): Node[] {
const span = document.createElement('span');
span.id = 'query-display';
span.textContent = props.target ? 'HAS_TARGET' : 'NO_TARGET';
return [span];
}
function ReactiveTitleWidget(props: {title: () => string; injector: Injector}): Node[] {
const span = document.createElement('span');
span.id = 'title-display';
effect(
() => {
span.textContent = props.title();
},
{injector: props.injector},
);
return [span];
}
function ReactiveQueryWidget(props: {target: () => any; injector: Injector}): Node[] {
const span = document.createElement('span');
span.id = 'query-display';
effect(
() => {
const target = props.target();
span.textContent = target ? 'HAS_TARGET' : 'NO_TARGET';
},
{injector: props.injector},
);
return [span];
}
it('should support passing required inputs to foreign component props', async () => {
@Component({
selector: 'foreign-host',
template: `<TitleWidget [title]="title()" />`,
// @ts-ignore
foreignImports: [frameworkImport(TitleWidget)],
})
class ForeignHost {
readonly title = input.required<string>();
}
@Component({
imports: [ForeignHost],
template: `<foreign-host [title]="parentTitle()" />`,
})
class App {
readonly parentTitle = signal('Required Title');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#title-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('Required Title');
});
it('should support passing raw input.required signal to foreign component props and track reactively', async () => {
@Component({
selector: 'foreign-host',
template: `<ReactiveTitleWidget [title]="title" [injector]="injector" />`,
// @ts-ignore
foreignImports: [frameworkImport(ReactiveTitleWidget)],
})
class ForeignHost {
readonly title = input.required<string>();
readonly injector = inject(Injector);
}
@Component({
imports: [ForeignHost],
template: `<foreign-host [title]="parentTitle()" />`,
})
class App {
readonly parentTitle = signal('Initial Title');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#title-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('Initial Title');
fixture.componentInstance.parentTitle.set('Updated Title');
await fixture.whenStable();
expect(span.textContent).toBe('Updated Title');
});
it('should support passing @Input() properties to foreign component props', async () => {
@Component({
selector: 'foreign-input-host',
template: `<TitleWidget [title]="title" />`,
// @ts-ignore
foreignImports: [frameworkImport(TitleWidget)],
})
class ForeignInputHost {
@Input() title = 'default';
}
@Component({
imports: [ForeignInputHost],
template: `<foreign-input-host [title]="parentTitle" />`,
})
class App {
parentTitle = 'Passed from Parent';
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#title-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('Passed from Parent');
});
it('should support passing ngOnInit initialized properties to foreign component props', async () => {
@Component({
template: `<TitleWidget [title]="title" />`,
// @ts-ignore
foreignImports: [frameworkImport(TitleWidget)],
})
class App implements OnInit {
title!: string;
ngOnInit() {
this.title = 'Initialized in ngOnInit';
}
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#title-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('Initialized in ngOnInit');
});
it('should support passing viewChild query results to foreign component props', async () => {
@Component({
template: `
<div #myDiv id="my-div">Hello</div>
<QueryWidget [target]="myDivRef()" />
`,
// @ts-ignore
foreignImports: [frameworkImport(QueryWidget)],
})
class App {
readonly myDivRef = viewChild<ElementRef>('myDiv');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#query-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('HAS_TARGET');
});
it('should support passing viewChild.required query results to foreign component props', async () => {
@Component({
template: `
<div #myDiv id="my-div">Hello</div>
<QueryWidget [target]="myDivRef()" />
`,
// @ts-ignore
foreignImports: [frameworkImport(QueryWidget)],
})
class App {
readonly myDivRef = viewChild.required<ElementRef>('myDiv');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#query-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('HAS_TARGET');
});
it('should support passing raw viewChild signal to foreign component props and track reactively when query changes', async () => {
@Component({
template: `
@if (showDiv()) {
<div #myDiv id="my-div">Hello</div>
}
<ReactiveQueryWidget [target]="myDivRef" [injector]="injector" />
`,
// @ts-ignore
foreignImports: [frameworkImport(ReactiveQueryWidget)],
})
class App {
readonly showDiv = signal(true);
readonly myDivRef = viewChild<ElementRef>('myDiv');
readonly injector = inject(Injector);
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#query-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('HAS_TARGET');
fixture.componentInstance.showDiv.set(false);
await fixture.whenStable();
expect(span.textContent).toBe('NO_TARGET');
fixture.componentInstance.showDiv.set(true);
await fixture.whenStable();
expect(span.textContent).toBe('HAS_TARGET');
});
it('should support passing raw viewChild.required signal to foreign component props and track reactively', async () => {
@Component({
template: `
<div #myDiv id="my-div">Hello</div>
<ReactiveQueryWidget [target]="myDivRef" [injector]="injector" />
`,
// @ts-ignore
foreignImports: [frameworkImport(ReactiveQueryWidget)],
})
class App {
readonly myDivRef = viewChild.required<ElementRef>('myDiv');
readonly injector = inject(Injector);
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const span = fixture.nativeElement.querySelector('#query-display');
expect(span).toBeTruthy();
expect(span.textContent).toBe('HAS_TARGET');
});
it('should attach foreign component DOM before ngAfterViewInit', async () => {
let textInAfterViewInit = '';
@Component({
selector: 'test-cmp',
template: `<TitleWidget title="Rendered Before ViewInit" />`,
// @ts-ignore
foreignImports: [frameworkImport(TitleWidget)],
})
class TestCmp implements AfterViewInit {
private readonly elementRef = inject(ElementRef);
ngAfterViewInit() {
const span = this.elementRef.nativeElement.querySelector('#title-display');
textInAfterViewInit = span?.textContent ?? '';
}
}
const fixture = TestBed.createComponent(TestCmp);
await fixture.whenStable();
expect(textInAfterViewInit).toBe('Rendered Before ViewInit');
});
});
});
@@ -20,6 +20,7 @@ import {ɵɵtext} from '../../src/render3/instructions/text';
import {ɵɵadvance} from '../../src/render3/instructions/advance';
import {ɵɵtextInterpolate2} from '../../src/render3/instructions/text_interpolation';
import {createLView} from '../../src/render3/view/construction';
import {refreshView} from '../../src/render3/instructions/change_detection';
import {renderView} from '../../src/render3/instructions/render';
import {LView, LViewFlags, PARENT, RENDERER, T_HOST} from '../../src/render3/interfaces/view';
@@ -46,6 +47,7 @@ describe('ɵɵforeignComponent', () => {
ɵɵforeignComponent(0, 0);
},
});
fixture.update(() => {});
expect(fixture.host.innerHTML).toContain('<div id="foreign-el">Foreign Content</div>');
});
@@ -54,16 +56,43 @@ describe('ɵɵforeignComponent', () => {
const render = jasmine.createSpy('render').and.returnValue([[]]);
const foreignComp = foreignImport<{name: string}>(render, noopOnDestroy, eagerContentAdapter);
new ViewFixture({
const fixture = new ViewFixture({
decls: 1,
vars: 0,
consts: [foreignComp],
create: () => {
ɵɵforeignComponent(0, 0, {name: 'Angular'});
ɵɵforeignComponent(0, 0, () => ({name: 'Angular'}));
},
});
expect(render).not.toHaveBeenCalled();
fixture.update(() => {});
expect(render).toHaveBeenCalledOnceWith({name: 'Angular'}, /* context= */ undefined);
});
it('should support passing props as a factory function and evaluate during update pass', () => {
const render = jasmine.createSpy('render').and.returnValue([[]]);
const propsFactory = jasmine
.createSpy('propsFactory')
.and.returnValue({name: 'Deferred Angular'});
const foreignComp = foreignImport<{name: string}>(render, noopOnDestroy, eagerContentAdapter);
const fixture = new ViewFixture({
decls: 1,
vars: 0,
consts: [foreignComp],
create: () => {
ɵɵforeignComponent(0, 0, propsFactory);
},
});
expect(render).toHaveBeenCalledOnceWith({name: 'Angular'}, /* context= */ undefined);
expect(propsFactory).not.toHaveBeenCalled();
expect(render).not.toHaveBeenCalled();
fixture.update(() => {});
expect(propsFactory).toHaveBeenCalledTimes(1);
expect(render).toHaveBeenCalledOnceWith({name: 'Deferred Angular'}, /* context= */ undefined);
});
it('should call the dispose function when the containing view is destroyed', () => {
@@ -84,6 +113,7 @@ describe('ɵɵforeignComponent', () => {
ɵɵforeignComponent(0, 0);
},
});
fixture.update(() => {});
expect(dispose).toHaveBeenCalledTimes(0);
@@ -113,6 +143,7 @@ describe('ɵɵforeignComponent', () => {
ɵɵelement(2, 'span');
},
});
fixture.update(() => {});
expect(fixture.host.innerHTML).toContain(
'' +
@@ -146,6 +177,7 @@ describe('ɵɵforeignComponent', () => {
ɵɵelementEnd();
},
});
fixture.update(() => {});
expect(fixture.host.innerHTML).toContain(
'' +
@@ -185,6 +217,7 @@ describe('ɵɵforeignComponent', () => {
consts: [foreignComp1],
create: createFn,
});
fixture.update(() => {});
expect(fixture.host.innerHTML).toContain(expectedHtml);
// Create second instance reusing the TView
@@ -262,13 +295,17 @@ describe('ɵɵforeignComponent', () => {
ɵɵdomTemplate(0, iconTemplate, 2, 0);
ɵɵdomTemplate(1, descriptionTemplate, 2, 0);
ɵɵdomTemplate(2, childrenTemplate, 2, 0);
ɵɵforeignComponent(3, 0, {
icon: ɵɵforeignContent(0, 0),
description: ɵɵforeignContent(1, 0),
children: ɵɵforeignContent(2, 0),
});
const iconContent = ɵɵforeignContent(0, 0);
const descriptionContent = ɵɵforeignContent(1, 0);
const childrenContent = ɵɵforeignContent(2, 0);
ɵɵforeignComponent(3, 0, () => ({
icon: iconContent,
description: descriptionContent,
children: childrenContent,
}));
},
});
fixture.update(() => {});
expect(fixture.host.innerHTML).toContain(
'' +
@@ -324,9 +361,10 @@ describe('ɵɵforeignComponent', () => {
consts: [foreignComp],
create: () => {
ɵɵdomTemplate(0, itemTemplate, 2, 2);
ɵɵforeignComponent(1, 0, {
renderItem: ɵɵforeignContentFn(0, 0),
});
const renderItem = ɵɵforeignContentFn(0, 0);
ɵɵforeignComponent(1, 0, () => ({
renderItem,
}));
},
});
@@ -365,5 +403,6 @@ function renderSecondInstance(fixture: ViewFixture): HTMLElement {
);
renderView(fixture.tView, lView, {});
refreshView(fixture.tView, lView, fixture.tView.template, {});
return host;
}
+1 -1
View File
@@ -115,7 +115,7 @@ export class ViewFixture {
{
rendererFactory,
sanitizer: sanitizer || null,
changeDetectionScheduler: null,
changeDetectionScheduler: {notify: noop, runningTick: false},
ngReflect: false,
tracingService: null,
},