Skip to content

Commit 47c37b2

Browse files
committed
fix(cdk/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 00f3274 commit 47c37b2

File tree

6 files changed

+135
-83
lines changed

6 files changed

+135
-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 {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
3+
import {waitForAsync, 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

+61-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
Injectable,
1616
EventEmitter,
1717
NgZone,
18+
ComponentFactoryResolver,
19+
TemplateRef,
1820
} from '@angular/core';
1921
import {Direction, Directionality} from '@angular/cdk/bidi';
2022
import {MockNgZone, dispatchFakeEvent} from '@angular/cdk/testing/private';
@@ -997,6 +999,31 @@ describe('Overlay', () => {
997999
}));
9981000

9991001
});
1002+
1003+
it('should insert component that adds content through ViewContainerRef', () => {
1004+
componentPortal = new ComponentPortal(FoodRenderer);
1005+
const overlayRef = overlay.create();
1006+
overlayRef.attach(componentPortal);
1007+
1008+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1009+
overlayRef.dispose();
1010+
});
1011+
1012+
it('should insert template that has component that adds content via ViewContainerRef', () => {
1013+
const fixture = TestBed.createComponent(FoodRendererInTemplate);
1014+
fixture.detectChanges();
1015+
1016+
const portal = new TemplatePortal(
1017+
fixture.componentInstance.foodRendererTemplate,
1018+
fixture.componentInstance.viewContainerRef
1019+
);
1020+
const overlayRef = overlay.create();
1021+
overlayRef.attach(portal);
1022+
1023+
expect(overlayContainerElement.textContent).toContain('Food:Pizza');
1024+
overlayRef.dispose();
1025+
});
1026+
10001027
});
10011028

10021029
/** Simple component for testing ComponentPortal. */
@@ -1015,9 +1042,42 @@ class TestComponentWithTemplatePortals {
10151042
constructor(public viewContainerRef: ViewContainerRef) { }
10161043
}
10171044

1045+
@Component({
1046+
selector: 'food-renderer',
1047+
template: '<p>Food:</p>',
1048+
})
1049+
class FoodRenderer {
1050+
constructor(
1051+
private _componentFactoryResolver: ComponentFactoryResolver,
1052+
private _viewContainerRef: ViewContainerRef) {
1053+
const factory = this._componentFactoryResolver.resolveComponentFactory(PizzaMsg);
1054+
this._viewContainerRef.clear();
1055+
this._viewContainerRef.createComponent(factory);
1056+
}
1057+
}
1058+
1059+
@Component({
1060+
template: `
1061+
<ng-template #foodRenderer>
1062+
<food-renderer></food-renderer>
1063+
</ng-template>
1064+
`
1065+
})
1066+
class FoodRendererInTemplate {
1067+
@ViewChild('foodRenderer') foodRendererTemplate: TemplateRef<any>;
1068+
1069+
constructor(public viewContainerRef: ViewContainerRef) { }
1070+
}
1071+
10181072
// Create a real (non-test) NgModule as a workaround for
10191073
// https://github.com/angular/angular/issues/10760
1020-
const TEST_COMPONENTS = [PizzaMsg, TestComponentWithTemplatePortals];
1074+
const TEST_COMPONENTS = [
1075+
PizzaMsg,
1076+
TestComponentWithTemplatePortals,
1077+
FoodRenderer,
1078+
FoodRendererInTemplate,
1079+
];
1080+
10211081
@NgModule({
10221082
imports: [OverlayModule, PortalModule],
10231083
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';
@@ -444,19 +442,6 @@ describe('Portals', () => {
444442
expect(someDomElement.innerHTML).toBe('');
445443
});
446444

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

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

686649
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
687650
}
@@ -774,7 +737,7 @@ const TEST_COMPONENTS = [
774737
@NgModule({
775738
imports: [CommonModule, PortalModule],
776739
exports: TEST_COMPONENTS,
777-
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
740+
declarations: TEST_COMPONENTS,
778741
entryComponents: TEST_COMPONENTS,
779742
})
780743
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)