From 60f8a785fd29b9c8a5338ccff3f86c1b54babb2b Mon Sep 17 00:00:00 2001 From: leonsenft Date: Tue, 25 Aug 2026 21:08:41 +0000 Subject: [PATCH] refactor(core): defer foreign component rendering to post-update pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 34817da7354f2a4e55f277a991d4345a4ca8a91d) --- .../standalone/foreign_component.js | 12 +- .../standalone/foreign_component.local.js | 10 +- .../src/template/pipeline/src/phases/reify.ts | 3 +- .../src/phases/resolve_foreign_content.ts | 18 +- .../render3/instructions/foreign_component.ts | 63 ++-- .../foreign_component_spec.ts | 280 ++++++++++++++++++ .../test/render3/foreign_component_spec.ts | 61 +++- packages/core/test/render3/view_fixture.ts | 2 +- 8 files changed, 403 insertions(+), 46 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 5a11fdb2830..2dfc8490995 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 @@ -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 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 94d78e02412..f883a667095 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 @@ -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 diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index 896a46fdbc9..2aca00a98ea 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -144,7 +144,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList 0 ? o.literalMap( Array.from(op.props.entries()).map(([key, value]) => ({ @@ -154,6 +154,7 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList( + varXref, + variable, + foreignContent, + ir.VariableFlags.None, + ); + ir.OpList.insertBefore(varOp, target); + + target.props.set(op.propertyName, new ir.ReadVariableExpr(varXref)); } } } diff --git a/packages/core/src/render3/instructions/foreign_component.ts b/packages/core/src/render3/instructions/foreign_component.ts index 453e3e02502..af1bab475b8 100644 --- a/packages/core/src/render3/instructions/foreign_component.ts +++ b/packages/core/src/render3/instructions/foreign_component.ts @@ -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, []); }; 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 b2be0e26f9c..34b189f11aa 100644 --- a/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts +++ b/packages/core/test/acceptance/foreign_component/foreign_component_spec.ts @@ -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: ``, + // @ts-ignore + foreignImports: [frameworkImport(TitleWidget)], + }) + class ForeignHost { + readonly title = input.required(); + } + + @Component({ + imports: [ForeignHost], + template: ``, + }) + 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: ``, + // @ts-ignore + foreignImports: [frameworkImport(ReactiveTitleWidget)], + }) + class ForeignHost { + readonly title = input.required(); + readonly injector = inject(Injector); + } + + @Component({ + imports: [ForeignHost], + template: ``, + }) + 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: ``, + // @ts-ignore + foreignImports: [frameworkImport(TitleWidget)], + }) + class ForeignInputHost { + @Input() title = 'default'; + } + + @Component({ + imports: [ForeignInputHost], + template: ``, + }) + 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: ``, + // @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: ` +
Hello
+ + `, + // @ts-ignore + foreignImports: [frameworkImport(QueryWidget)], + }) + class App { + readonly myDivRef = viewChild('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: ` +
Hello
+ + `, + // @ts-ignore + foreignImports: [frameworkImport(QueryWidget)], + }) + class App { + readonly myDivRef = viewChild.required('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()) { +
Hello
+ } + + `, + // @ts-ignore + foreignImports: [frameworkImport(ReactiveQueryWidget)], + }) + class App { + readonly showDiv = signal(true); + readonly myDivRef = viewChild('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: ` +
Hello
+ + `, + // @ts-ignore + foreignImports: [frameworkImport(ReactiveQueryWidget)], + }) + class App { + readonly myDivRef = viewChild.required('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: ``, + // @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'); + }); + }); }); diff --git a/packages/core/test/render3/foreign_component_spec.ts b/packages/core/test/render3/foreign_component_spec.ts index 8b6aeef1bfb..e5e5171dc55 100644 --- a/packages/core/test/render3/foreign_component_spec.ts +++ b/packages/core/test/render3/foreign_component_spec.ts @@ -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('
Foreign Content
'); }); @@ -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; } diff --git a/packages/core/test/render3/view_fixture.ts b/packages/core/test/render3/view_fixture.ts index 2ec77dc8f92..281ae848cba 100644 --- a/packages/core/test/render3/view_fixture.ts +++ b/packages/core/test/render3/view_fixture.ts @@ -115,7 +115,7 @@ export class ViewFixture { { rendererFactory, sanitizer: sanitizer || null, - changeDetectionScheduler: null, + changeDetectionScheduler: {notify: noop, runningTick: false}, ngReflect: false, tracingService: null, },