Skip to content

Commit 0d790d0

Browse files
committed
fix(portal): running change detection before nodes have been moved to outlet
Currently we run change detection as soon as we create a portal's embedded view and afterwards we transfer its DOM nodes into the portal outlet. This is problematic, because running change detection also executes any lifecycle hooks which means that directives inside the portal, which are looking at the DOM, will be invoked while the element is still at its old location. Fixes #16346.
1 parent 8008218 commit 0d790d0

File tree

4 files changed

+96
-6
lines changed

4 files changed

+96
-6
lines changed

src/cdk/a11y/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ ng_test_library(
4949
"//src/cdk/keycodes",
5050
"//src/cdk/observers",
5151
"//src/cdk/platform",
52+
"//src/cdk/portal",
5253
"//src/cdk/testing/private",
5354
"@npm//@angular/platform-browser",
5455
"@npm//rxjs",

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

+46-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {Platform} from '@angular/cdk/platform';
2-
import {Component, ViewChild} from '@angular/core';
2+
import {Component, ViewChild, TemplateRef, ViewContainerRef} from '@angular/core';
33
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
4+
import {PortalModule, CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
45
import {A11yModule, FocusTrap, CdkTrapFocus} from '../index';
56

67

78
describe('FocusTrap', () => {
89

910
beforeEach(async(() => {
1011
TestBed.configureTestingModule({
11-
imports: [A11yModule],
12+
imports: [A11yModule, PortalModule],
1213
declarations: [
1314
FocusTrapWithBindings,
1415
SimpleFocusTrap,
@@ -17,6 +18,7 @@ describe('FocusTrap', () => {
1718
FocusTrapWithoutFocusableElements,
1819
FocusTrapWithAutoCapture,
1920
FocusTrapUnfocusableTarget,
21+
FocusTrapInsidePortal,
2022
],
2123
});
2224

@@ -187,6 +189,27 @@ describe('FocusTrap', () => {
187189
});
188190
}));
189191
});
192+
193+
it('should put anchors inside the outlet when set at the root of a template portal', () => {
194+
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
195+
const instance = fixture.componentInstance;
196+
fixture.detectChanges();
197+
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');
198+
199+
expect(outlet.querySelectorAll('button').length)
200+
.toBe(0, 'Expected no buttons inside the outlet on init.');
201+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
202+
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');
203+
204+
const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
205+
instance.portalOutlet.attachTemplatePortal(portal);
206+
fixture.detectChanges();
207+
208+
expect(outlet.querySelectorAll('button').length)
209+
.toBe(1, 'Expected one button inside the outlet after attaching.');
210+
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
211+
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
212+
});
190213
});
191214

192215

@@ -283,3 +306,24 @@ class FocusTrapWithSvg {
283306
class FocusTrapWithoutFocusableElements {
284307
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
285308
}
309+
310+
311+
@Component({
312+
template: `
313+
<div class="portal-outlet">
314+
<ng-template cdkPortalOutlet></ng-template>
315+
</div>
316+
317+
<ng-template #template>
318+
<div cdkTrapFocus>
319+
<button>Click me</button>
320+
</div>
321+
</ng-template>
322+
`,
323+
})
324+
class FocusTrapInsidePortal {
325+
@ViewChild('template', {static: false}) template: TemplateRef<any>;
326+
@ViewChild(CdkPortalOutlet, {static: false}) portalOutlet: CdkPortalOutlet;
327+
328+
constructor(public viewContainerRef: ViewContainerRef) {}
329+
}

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

+5-1
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,18 @@ export class DomPortalOutlet extends BasePortalOutlet {
8383
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
8484
let viewContainer = portal.viewContainerRef;
8585
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
86-
viewRef.detectChanges();
8786

8887
// The method `createEmbeddedView` will add the view as a child of the viewContainer.
8988
// But for the DomPortalOutlet the view can be added everywhere in the DOM
9089
// (e.g Overlay Container) To move the view to the specified host element. We just
9190
// re-append the existing root nodes.
9291
viewRef.rootNodes.forEach(rootNode => this.outletElement.appendChild(rootNode));
9392

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();
97+
9498
this.setDisposeFn((() => {
9599
let index = viewContainer.indexOf(viewRef);
96100
if (index !== -1) {

src/cdk/portal/portal.spec.ts

+44-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
ViewChild,
1515
ViewChildren,
1616
ViewContainerRef,
17+
Directive,
18+
AfterViewInit,
1719
} from '@angular/core';
1820
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
1921
import {DomPortalOutlet} from './dom-portal-outlet';
@@ -404,7 +406,7 @@ describe('Portals', () => {
404406
let componentFactoryResolver: ComponentFactoryResolver;
405407
let someViewContainerRef: ViewContainerRef;
406408
let someInjector: Injector;
407-
let someFixture: ComponentFixture<any>;
409+
let someFixture: ComponentFixture<ArbitraryViewContainerRefComponent>;
408410
let someDomElement: HTMLElement;
409411
let host: DomPortalOutlet;
410412
let injector: Injector;
@@ -440,6 +442,19 @@ describe('Portals', () => {
440442
expect(someDomElement.innerHTML).toBe('');
441443
});
442444

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+
443458
it('should attach and detach a component portal with a given injector', () => {
444459
let fixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
445460
someViewContainerRef = fixture.componentInstance.viewContainerRef;
@@ -634,12 +649,38 @@ class PizzaMsg {
634649
constructor(@Optional() public snack: Chocolate) { }
635650
}
636651

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+
637669
/** Simple component to grab an arbitrary ViewContainerRef */
638670
@Component({
639671
selector: 'some-placeholder',
640-
template: '<p>Hello</p>'
672+
template: `
673+
<p>Hello</p>
674+
675+
<ng-template #template>
676+
<div savesParentNodeOnInit></div>
677+
</ng-template>
678+
`
641679
})
642680
class ArbitraryViewContainerRefComponent {
681+
@ViewChild('template', {static: false}) template: TemplateRef<any>;
682+
@ViewChild(SaveParentNodeOnInit, {static: false}) saveParentNodeOnInit: SaveParentNodeOnInit;
683+
643684
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
644685
}
645686

@@ -731,7 +772,7 @@ const TEST_COMPONENTS = [
731772
@NgModule({
732773
imports: [CommonModule, PortalModule],
733774
exports: TEST_COMPONENTS,
734-
declarations: TEST_COMPONENTS,
775+
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
735776
entryComponents: TEST_COMPONENTS,
736777
})
737778
class PortalTestModule { }

0 commit comments

Comments
 (0)