Skip to content

Commit 27dcbe2

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`. Fixes #20343.
1 parent 23d3c21 commit 27dcbe2

File tree

6 files changed

+130
-83
lines changed

6 files changed

+130
-83
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

+31-6
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; }
@@ -407,7 +410,12 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
407410
constructor(
408411
private _elementRef: ElementRef<HTMLElement>,
409412
private _focusTrapFactory: FocusTrapFactory,
410-
@Inject(DOCUMENT) _document: any) {
413+
@Inject(DOCUMENT) _document: any,
414+
/**
415+
* @deprecated _ngZone parameter to become required.
416+
* @breaking-change 11.0.0
417+
*/
418+
private _ngZone?: NgZone) {
411419

412420
this._document = _document;
413421
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
@@ -425,15 +433,32 @@ export class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChanges, DoC
425433
}
426434

427435
ngAfterContentInit() {
428-
this.focusTrap.attachAnchors();
429-
430-
if (this.autoCapture) {
431-
this._captureFocus();
436+
// We have to delay attaching the anchors, because the lifecycle hook may be fired before the
437+
// focus trap element is at its final place in the DOM when inserted via a portal (see #16346).
438+
// It has to happen outside the `NgZone`, because the promise can delay `NgZone.onStable` which
439+
// overlays depend on for positioning.
440+
const attachAnchor = () => {
441+
Promise.resolve().then(() => {
442+
this.focusTrap.attachAnchors();
443+
444+
if (this.autoCapture) {
445+
this._captureFocus();
446+
}
447+
448+
this._hasInitialized = true;
449+
});
450+
};
451+
452+
// @breaking-change 11.0.0 Remove null check for `_ngZone`.
453+
if (this._ngZone) {
454+
this._ngZone.runOutsideAngular(attachAnchor);
455+
} else {
456+
attachAnchor();
432457
}
433458
}
434459

435460
ngDoCheck() {
436-
if (!this.focusTrap.hasAttached()) {
461+
if (this._hasInitialized && !this.focusTrap.hasAttached()) {
437462
this.focusTrap.attachAnchors();
438463
}
439464
}

src/cdk/overlay/overlay.spec.ts

+56-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,22 @@ describe('Overlay', () => {
990993
}));
991994

992995
});
996+
997+
it('should insert template that has component that adds content via ViewContainerRef', () => {
998+
const fixture = TestBed.createComponent(FoodRendererInTemplate);
999+
fixture.detectChanges();
1000+
1001+
const portal = new TemplatePortal(
1002+
fixture.componentInstance.foodRendererTemplate,
1003+
fixture.componentInstance.viewContainerRef
1004+
);
1005+
const overlayRef = overlay.create();
1006+
overlayRef.attach(portal);
1007+
1008+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1009+
overlayRef.dispose();
1010+
});
1011+
9931012
});
9941013

9951014
/** Simple component for testing ComponentPortal. */
@@ -1008,9 +1027,45 @@ class TestComponentWithTemplatePortals {
10081027
constructor(public viewContainerRef: ViewContainerRef) { }
10091028
}
10101029

1030+
@Component({
1031+
selector: 'food-renderer',
1032+
template: '<p>Food:</p>',
1033+
})
1034+
class FoodRenderer implements OnInit {
1035+
constructor(
1036+
private _componentFactoryResolver: ComponentFactoryResolver,
1037+
private _viewContainerRef: ViewContainerRef) {
1038+
}
1039+
1040+
ngOnInit() {
1041+
const factory = this._componentFactoryResolver.resolveComponentFactory(PizzaMsg);
1042+
this._viewContainerRef.clear();
1043+
this._viewContainerRef.createComponent(factory);
1044+
}
1045+
}
1046+
1047+
@Component({
1048+
template: `
1049+
<ng-template #foodRenderer>
1050+
<food-renderer></food-renderer>
1051+
</ng-template>
1052+
`
1053+
})
1054+
class FoodRendererInTemplate {
1055+
@ViewChild('foodRenderer') foodRendererTemplate: TemplateRef<any>;
1056+
1057+
constructor(public viewContainerRef: ViewContainerRef) { }
1058+
}
1059+
10111060
// Create a real (non-test) NgModule as a workaround for
10121061
// https://github.com/angular/angular/issues/10760
1013-
const TEST_COMPONENTS = [PizzaMsg, TestComponentWithTemplatePortals];
1062+
const TEST_COMPONENTS = [
1063+
PizzaMsg,
1064+
TestComponentWithTemplatePortals,
1065+
FoodRenderer,
1066+
FoodRendererInTemplate,
1067+
];
1068+
10141069
@NgModule({
10151070
imports: [OverlayModule, PortalModule],
10161071
exports: TEST_COMPONENTS,

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

+14-14
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class DomPortalOutlet extends BasePortalOutlet {
7070
}
7171
// At this point the component has been instantiated, so we move it to the location in the DOM
7272
// where we want it to be rendered.
73-
this.outletElement.appendChild(this._getComponentRootNode(componentRef));
73+
this._transferRootNodes(componentRef.hostView as EmbeddedViewRef<any>);
7474

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

8792
// The method `createEmbeddedView` will add the view as a child of the viewContainer.
8893
// But for the DomPortalOutlet the view can be added everywhere in the DOM
8994
// (e.g Overlay Container) To move the view to the specified host element. We just
9095
// 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();
96+
this._transferRootNodes(viewRef);
9797

9898
this.setDisposeFn((() => {
99-
let index = viewContainer.indexOf(viewRef);
100-
if (index !== -1) {
99+
const index = viewContainer.indexOf(viewRef);
100+
if (index > -1) {
101101
viewContainer.remove(index);
102102
}
103103
}));
@@ -149,9 +149,9 @@ export class DomPortalOutlet extends BasePortalOutlet {
149149
}
150150
}
151151

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;
152+
/** Moves all of the root nodes of an embedded view into the outlet element. */
153+
private _transferRootNodes(viewRef: EmbeddedViewRef<any>): void {
154+
viewRef.rootNodes.forEach(node => this.outletElement.appendChild(node));
155155
}
156156
}
157157

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

tools/public_api_guard/cdk/a11y.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ export declare class CdkTrapFocus implements OnDestroy, AfterContentInit, OnChan
4949
get enabled(): boolean;
5050
set enabled(value: boolean);
5151
focusTrap: FocusTrap;
52-
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _document: any);
52+
constructor(_elementRef: ElementRef<HTMLElement>, _focusTrapFactory: FocusTrapFactory, _document: any,
53+
_ngZone?: NgZone | undefined);
5354
ngAfterContentInit(): void;
5455
ngDoCheck(): void;
5556
ngOnChanges(changes: SimpleChanges): void;

0 commit comments

Comments
 (0)