Skip to content

Commit f36a557

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 93dc69f commit f36a557

File tree

4 files changed

+96
-6
lines changed

4 files changed

+96
-6
lines changed

src/cdk/a11y/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ ng_test_library(
5050
"//src/cdk/keycodes",
5151
"//src/cdk/observers",
5252
"//src/cdk/platform",
53+
"//src/cdk/portal",
5354
"//src/cdk/testing/private",
5455
"@npm//@angular/platform-browser",
5556
"@npm//rxjs",

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {Platform} from '@angular/cdk/platform';
2-
import {Component, PLATFORM_ID, ViewChild} from '@angular/core';
2+
import {Component, PLATFORM_ID, 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

Lines changed: 5 additions & 1 deletion
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

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
TemplateRef,
1414
ComponentRef,
1515
ElementRef,
16+
Directive,
17+
AfterViewInit,
1618
} from '@angular/core';
1719
import {CommonModule} from '@angular/common';
1820
import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives';
@@ -367,7 +369,7 @@ describe('Portals', () => {
367369
let componentFactoryResolver: ComponentFactoryResolver;
368370
let someViewContainerRef: ViewContainerRef;
369371
let someInjector: Injector;
370-
let someFixture: ComponentFixture<any>;
372+
let someFixture: ComponentFixture<ArbitraryViewContainerRefComponent>;
371373
let someDomElement: HTMLElement;
372374
let host: DomPortalOutlet;
373375
let injector: Injector;
@@ -403,6 +405,19 @@ describe('Portals', () => {
403405
expect(someDomElement.innerHTML).toBe('');
404406
});
405407

408+
it('should move the DOM nodes before running change detection', () => {
409+
someFixture.detectChanges();
410+
let portal = new TemplatePortal(someFixture.componentInstance.template, someViewContainerRef);
411+
412+
host.attachTemplatePortal(portal);
413+
someFixture.detectChanges();
414+
415+
expect(someFixture.componentInstance.saveParentNodeOnInit.parentOnViewInit)
416+
.toBe(someDomElement);
417+
418+
host.dispose();
419+
});
420+
406421
it('should attach and detach a component portal with a given injector', () => {
407422
let fixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
408423
someViewContainerRef = fixture.componentInstance.viewContainerRef;
@@ -575,12 +590,38 @@ class PizzaMsg {
575590
constructor(@Optional() public snack: Chocolate) { }
576591
}
577592

593+
/**
594+
* Saves the parent node that the directive was attached to on init.
595+
* Useful to see where the element was in the DOM when it was first attached.
596+
*/
597+
@Directive({
598+
selector: '[savesParentNodeOnInit]'
599+
})
600+
class SaveParentNodeOnInit implements AfterViewInit {
601+
parentOnViewInit: HTMLElement;
602+
603+
constructor(private _elementRef: ElementRef<HTMLElement>) {}
604+
605+
ngAfterViewInit() {
606+
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
607+
}
608+
}
609+
578610
/** Simple component to grab an arbitrary ViewContainerRef */
579611
@Component({
580612
selector: 'some-placeholder',
581-
template: '<p>Hello</p>'
613+
template: `
614+
<p>Hello</p>
615+
616+
<ng-template #template>
617+
<div savesParentNodeOnInit></div>
618+
</ng-template>
619+
`
582620
})
583621
class ArbitraryViewContainerRefComponent {
622+
@ViewChild('template', {static: false}) template: TemplateRef<any>;
623+
@ViewChild(SaveParentNodeOnInit, {static: false}) saveParentNodeOnInit: SaveParentNodeOnInit;
624+
584625
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
585626
}
586627

@@ -666,7 +707,7 @@ const TEST_COMPONENTS = [
666707
@NgModule({
667708
imports: [CommonModule, PortalModule],
668709
exports: TEST_COMPONENTS,
669-
declarations: TEST_COMPONENTS,
710+
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
670711
entryComponents: TEST_COMPONENTS,
671712
})
672713
class PortalTestModule { }

0 commit comments

Comments
 (0)