Skip to content

Commit 1a7a1a2

Browse files
committed
fix(portal): run change detection before inserting portal content
In #16407 we made some changes where we'd run change detection after transferring the portal content to the outlet, in order to handle a case where focus traps may be attached too early. This PR reverts back to triggering change detection before moving the content, because doing it after may leave behind nodes that were inserted through `ViewContainerRef` inside one one of the init hooks. The initial focus trap issue is resolved by delaying the attachment instead. These changes fix another issue where we were assuming that component portals can only have one root node. This is true for most cases, except for when a sibling is inserted through `ViewContainerRef`. Finally, these changes add a `detectChanges` call before moving the contents of a component portal for consistency between portals. Previously we were only doing in for template portals. Fixes #20343.
1 parent 23d3c21 commit 1a7a1a2

File tree

5 files changed

+126
-81
lines changed

5 files changed

+126
-81
lines changed

src/cdk/a11y/focus-trap/focus-trap.spec.ts

+26-23
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Platform} from '@angular/cdk/platform';
22
import {Component, ViewChild, TemplateRef, ViewContainerRef} from '@angular/core';
3-
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {async, ComponentFixture, TestBed, fakeAsync, tick} from '@angular/core/testing';
44
import {PortalModule, CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
55
import {A11yModule, FocusTrap, CdkTrapFocus} from '../index';
66

@@ -81,10 +81,11 @@ describe('FocusTrap', () => {
8181
describe('with bindings', () => {
8282
let fixture: ComponentFixture<FocusTrapWithBindings>;
8383

84-
beforeEach(() => {
84+
beforeEach(fakeAsync(() => {
8585
fixture = TestBed.createComponent(FocusTrapWithBindings);
8686
fixture.detectChanges();
87-
});
87+
tick();
88+
}));
8889

8990
it('should clean up its anchor sibling elements on destroy', () => {
9091
const rootElement = fixture.debugElement.nativeElement as HTMLElement;
@@ -212,26 +213,28 @@ describe('FocusTrap', () => {
212213

213214
});
214215

215-
it('should put anchors inside the outlet when set at the root of a template portal', () => {
216-
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
217-
const instance = fixture.componentInstance;
218-
fixture.detectChanges();
219-
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
220-
221-
expect(outlet.querySelectorAll('button').length)
222-
.toBe(0, 'Expected no buttons inside the outlet on init.');
223-
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
224-
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
225-
226-
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
227-
instance.portalOutlet.attachTemplatePortal(portal);
228-
fixture.detectChanges();
229-
230-
expect(outlet.querySelectorAll('button').length)
231-
.toBe(1, 'Expected one button inside the outlet after attaching.');
232-
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
233-
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
234-
});
216+
it('should put anchors inside the outlet when set at the root of a template portal',
217+
fakeAsync(() => {
218+
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
219+
const instance = fixture.componentInstance;
220+
fixture.detectChanges();
221+
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
222+
223+
expect(outlet.querySelectorAll('button').length)
224+
.toBe(0, 'Expected no buttons inside the outlet on init.');
225+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
226+
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
227+
228+
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
229+
instance.portalOutlet.attachTemplatePortal(portal);
230+
fixture.detectChanges();
231+
tick();
232+
233+
expect(outlet.querySelectorAll('button').length)
234+
.toBe(1, 'Expected one button inside the outlet after attaching.');
235+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
236+
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
237+
}));
235238
});
236239

237240

src/cdk/a11y/focus-trap/focus-trap.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
390390
/** Previously focused element to restore focus to upon destroy when using autoCapture. */
391391
private _previouslyFocusedElement: HTMLElement | null = null;
392392

393+
/** Whether the focus trap has been initialized. */
394+
private _hasInitialized: boolean;
395+
393396
/** Whether the focus trap is active. */
394397
@Input('cdkTrapFocus')
395398
get enabled(): boolean { return this.focusTrap.enabled; }
@@ -425,15 +428,21 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
425428
}
426429

427430
ngAfterContentInit() {
428-
this.focusTrap.attachAnchors();
431+
// We have to delay attaching the anchors, because the lifecycle hook may be fired before the
432+
// focus trap element is at its final place in the DOM when inserted via a portal (see #16346).
433+
Promise.resolve().then(() => {
434+
this.focusTrap.attachAnchors();
429435

430-
if (this.autoCapture) {
431-
this._captureFocus();
432-
}
436+
if (this.autoCapture) {
437+
this._captureFocus();
438+
}
439+
440+
this._hasInitialized = true;
441+
});
433442
}
434443

435444
ngDoCheck() {
436-
if (!this.focusTrap.hasAttached()) {
445+
if (this._hasInitialized && !this.focusTrap.hasAttached()) {
437446
this.focusTrap.attachAnchors();
438447
}
439448
}

src/cdk/overlay/overlay.spec.ts

+65-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
Injectable,
99
EventEmitter,
1010
NgZone,
11+
ComponentFactoryResolver,
12+
OnInit,
13+
TemplateRef,
1114
} from '@angular/core';
1215
import {Direction, Directionality} from '@angular/cdk/bidi';
1316
import {MockNgZone, dispatchFakeEvent} from '@angular/cdk/testing/private';
@@ -990,6 +993,31 @@ describe('Overlay', () => {
990993
}));
991994

992995
});
996+
997+
it('should insert component that adds content through ViewContainerRef', () => {
998+
componentPortal = new ComponentPortal(FoodRenderer);
999+
const overlayRef = overlay.create();
1000+
overlayRef.attach(componentPortal);
1001+
1002+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1003+
overlayRef.dispose();
1004+
});
1005+
1006+
it('should insert template that has component that adds content via ViewContainerRef', () => {
1007+
const fixture = TestBed.createComponent(FoodRendererInTemplate);
1008+
fixture.detectChanges();
1009+
1010+
const portal = new TemplatePortal(
1011+
fixture.componentInstance.foodRendererTemplate,
1012+
fixture.componentInstance.viewContainerRef
1013+
);
1014+
const overlayRef = overlay.create();
1015+
overlayRef.attach(portal);
1016+
1017+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1018+
overlayRef.dispose();
1019+
});
1020+
9931021
});
9941022

9951023
/** Simple component for testing ComponentPortal. */
@@ -1008,9 +1036,45 @@ class TestComponentWithTemplatePortals {
10081036
constructor(public viewContainerRef: ViewContainerRef) { }
10091037
}
10101038

1039+
@Component({
1040+
selector: 'food-renderer',
1041+
template: '<p>Food:</p>',
1042+
})
1043+
class FoodRenderer implements OnInit {
1044+
constructor(
1045+
private _componentFactoryResolver: ComponentFactoryResolver,
1046+
private _viewContainerRef: ViewContainerRef) {
1047+
}
1048+
1049+
ngOnInit() {
1050+
const factory = this._componentFactoryResolver.resolveComponentFactory(PizzaMsg);
1051+
this._viewContainerRef.clear();
1052+
this._viewContainerRef.createComponent(factory);
1053+
}
1054+
}
1055+
1056+
@Component({
1057+
template: `
1058+
<ng-template #foodRenderer>
1059+
<food-renderer></food-renderer>
1060+
</ng-template>
1061+
`
1062+
})
1063+
class FoodRendererInTemplate {
1064+
@ViewChild('foodRenderer') foodRendererTemplate: TemplateRef<any>;
1065+
1066+
constructor(public viewContainerRef: ViewContainerRef) { }
1067+
}
1068+
10111069
// Create a real (non-test) NgModule as a workaround for
10121070
// https://github.com/angular/angular/issues/10760
1013-
const TEST_COMPONENTS = [PizzaMsg, TestComponentWithTemplatePortals];
1071+
const TEST_COMPONENTS = [
1072+
PizzaMsg,
1073+
TestComponentWithTemplatePortals,
1074+
FoodRenderer,
1075+
FoodRendererInTemplate,
1076+
];
1077+
10141078
@NgModule({
10151079
imports: [OverlayModule, PortalModule],
10161080
exports: TEST_COMPONENTS,

src/cdk/portal/dom-portal-outlet.ts

+20-14
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,15 @@ export class DomPortalOutlet extends BasePortalOutlet {
6868
componentRef.destroy();
6969
});
7070
}
71+
72+
// Trigger change detection so that we invoke the lifecycle hooks of any components inside the
73+
// component **before** moving the DOM nodes into their new position, because it can have an
74+
// effect on the amount of `rootNodes` (e.g. if something is inserted via a `ViewContainerRef`).
75+
componentRef.changeDetectorRef.detectChanges();
76+
7177
// At this point the component has been instantiated, so we move it to the location in the DOM
7278
// where we want it to be rendered.
73-
this.outletElement.appendChild(this._getComponentRootNode(componentRef));
79+
this._transferRootNodes(componentRef.hostView as EmbeddedViewRef<any>);
7480

7581
return componentRef;
7682
}
@@ -81,23 +87,23 @@ export class DomPortalOutlet extends BasePortalOutlet {
8187
* @returns Reference to the created embedded view.
8288
*/
8389
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
84-
let viewContainer = portal.viewContainerRef;
85-
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
90+
const viewContainer = portal.viewContainerRef;
91+
const viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
92+
93+
// Trigger change detection so that we invoke the lifecycle hooks of any components inside the
94+
// template **before** moving the DOM nodes into their new position, because it can have an
95+
// effect on the amount of `rootNodes` (e.g. if something is inserted via a `ViewContainerRef`).
96+
viewRef.detectChanges();
8697

8798
// The method `createEmbeddedView` will add the view as a child of the viewContainer.
8899
// But for the DomPortalOutlet the view can be added everywhere in the DOM
89100
// (e.g Overlay Container) To move the view to the specified host element. We just
90101
// re-append the existing root nodes.
91-
viewRef.rootNodes.forEach(rootNode => this.outletElement.appendChild(rootNode));
92-
93-
// Note that we want to detect changes after the nodes have been moved so that
94-
// any directives inside the portal that are looking at the DOM inside a lifecycle
95-
// hook won't be invoked too early.
96-
viewRef.detectChanges();
102+
this._transferRootNodes(viewRef);
97103

98104
this.setDisposeFn((() => {
99-
let index = viewContainer.indexOf(viewRef);
100-
if (index !== -1) {
105+
const index = viewContainer.indexOf(viewRef);
106+
if (index > -1) {
101107
viewContainer.remove(index);
102108
}
103109
}));
@@ -149,9 +155,9 @@ export class DomPortalOutlet extends BasePortalOutlet {
149155
}
150156
}
151157

152-
/** Gets the root HTMLElement for an instantiated component. */
153-
private _getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement {
154-
return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
158+
/** Moves all of the root nodes of an embedded view into the outlet element. */
159+
private _transferRootNodes(viewRef: EmbeddedViewRef<any>): void {
160+
viewRef.rootNodes.forEach(node => this.outletElement.appendChild(node));
155161
}
156162
}
157163

src/cdk/portal/portal.spec.ts

+1-38
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import {
1414
ViewChild,
1515
ViewChildren,
1616
ViewContainerRef,
17-
Directive,
18-
AfterViewInit,
1917
} from '@angular/core';
2018
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
2119
import {DomPortalOutlet} from './dom-portal-outlet';
@@ -442,19 +440,6 @@ describe('Portals', () => {
442440
expect(someDomElement.innerHTML).toBe('');
443441
});
444442

445-
it('should move the DOM nodes before running change detection', () => {
446-
someFixture.detectChanges();
447-
let portal = new TemplatePortal(someFixture.componentInstance.template, someViewContainerRef);
448-
449-
host.attachTemplatePortal(portal);
450-
someFixture.detectChanges();
451-
452-
expect(someFixture.componentInstance.saveParentNodeOnInit.parentOnViewInit)
453-
.toBe(someDomElement);
454-
455-
host.dispose();
456-
});
457-
458443
it('should attach and detach a component portal with a given injector', () => {
459444
let fixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
460445
someViewContainerRef = fixture.componentInstance.viewContainerRef;
@@ -649,37 +634,15 @@ class PizzaMsg {
649634
constructor(@Optional() public snack: Chocolate) { }
650635
}
651636

652-
/**
653-
* Saves the parent node that the directive was attached to on init.
654-
* Useful to see where the element was in the DOM when it was first attached.
655-
*/
656-
@Directive({
657-
selector: '[savesParentNodeOnInit]'
658-
})
659-
class SaveParentNodeOnInit implements AfterViewInit {
660-
parentOnViewInit: HTMLElement;
661-
662-
constructor(private _elementRef: ElementRef<HTMLElement>) {}
663-
664-
ngAfterViewInit() {
665-
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
666-
}
667-
}
668-
669637
/** Simple component to grab an arbitrary ViewContainerRef */
670638
@Component({
671639
selector: 'some-placeholder',
672640
template: `
673641
<p>Hello</p>
674-
675-
<ng-template #template>
676-
<div savesParentNodeOnInit></div>
677-
</ng-template>
678642
`
679643
})
680644
class ArbitraryViewContainerRefComponent {
681645
@ViewChild('template') template: TemplateRef<any>;
682-
@ViewChild(SaveParentNodeOnInit) saveParentNodeOnInit: SaveParentNodeOnInit;
683646

684647
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
685648
}
@@ -772,7 +735,7 @@ const TEST_COMPONENTS = [
772735
@NgModule({
773736
imports: [CommonModule, PortalModule],
774737
exports: TEST_COMPONENTS,
775-
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
738+
declarations: TEST_COMPONENTS,
776739
entryComponents: TEST_COMPONENTS,
777740
})
778741
class PortalTestModule { }

0 commit comments

Comments
 (0)