Skip to content

Commit 6edb710

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 0f6cd05 commit 6edb710

File tree

4 files changed

+114
-24
lines changed

4 files changed

+114
-24
lines changed

src/cdk/a11y/BUILD.bazel

+1
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

+46-2
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

+5-1
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,18 @@ export class DomPortalOutlet extends BasePortalOutlet {
7474
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
7575
let viewContainer = portal.viewContainerRef;
7676
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
77-
viewRef.detectChanges();
7877

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

84+
// Note that we want to detect changes after the nodes have been moved so that
85+
// any directives inside the portal that are looking at the DOM inside a lifecycle
86+
// hook won't be invoked too early.
87+
viewRef.detectChanges();
88+
8589
this.setDisposeFn((() => {
8690
let index = viewContainer.indexOf(viewRef);
8791
if (index !== -1) {

src/cdk/portal/portal.spec.ts

+62-21
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
ApplicationRef,
1313
TemplateRef,
1414
ComponentRef,
15+
Directive,
16+
AfterViewInit,
17+
ElementRef,
1518
} from '@angular/core';
1619
import {CommonModule} from '@angular/common';
1720
import {CdkPortal, CdkPortalOutlet, PortalModule} from './portal-directives';
@@ -336,8 +339,8 @@ describe('Portals', () => {
336339
let componentFactoryResolver: ComponentFactoryResolver;
337340
let someViewContainerRef: ViewContainerRef;
338341
let someInjector: Injector;
339-
let someFixture: ComponentFixture<any>;
340-
let someDomElement: HTMLElement;
342+
let someFixture: ComponentFixture<ArbitraryViewContainerRefComponent>;
343+
let outletNode: HTMLElement;
341344
let host: DomPortalOutlet;
342345
let injector: Injector;
343346
let appRef: ApplicationRef;
@@ -350,8 +353,8 @@ describe('Portals', () => {
350353
}));
351354

352355
beforeEach(() => {
353-
someDomElement = document.createElement('div');
354-
host = new DomPortalOutlet(someDomElement, componentFactoryResolver, appRef, injector);
356+
outletNode = document.createElement('div');
357+
host = new DomPortalOutlet(outletNode, componentFactoryResolver, appRef, injector);
355358

356359
someFixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
357360
someViewContainerRef = someFixture.componentInstance.viewContainerRef;
@@ -364,11 +367,23 @@ describe('Portals', () => {
364367
let componentInstance: PizzaMsg = portal.attach(host).instance;
365368

366369
expect(componentInstance instanceof PizzaMsg).toBe(true);
367-
expect(someDomElement.textContent).toContain('Pizza');
370+
expect(outletNode.textContent).toContain('Pizza');
368371

369372
host.detach();
370373

371-
expect(someDomElement.innerHTML).toBe('');
374+
expect(outletNode.innerHTML).toBe('');
375+
});
376+
377+
it('should move the DOM nodes before running change detection', () => {
378+
someFixture.detectChanges();
379+
let portal = new TemplatePortal(someFixture.componentInstance.template, someViewContainerRef);
380+
381+
host.attachTemplatePortal(portal);
382+
someFixture.detectChanges();
383+
384+
expect(someFixture.componentInstance.saveParentNodeOnInit.parentOnViewInit).toBe(outletNode);
385+
386+
host.dispose();
372387
});
373388

374389
it('should attach and detach a component portal with a given injector', () => {
@@ -383,12 +398,12 @@ describe('Portals', () => {
383398
fixture.detectChanges();
384399

385400
expect(componentInstance instanceof PizzaMsg).toBe(true);
386-
expect(someDomElement.textContent).toContain('Pizza');
387-
expect(someDomElement.textContent).toContain('Chocolate');
401+
expect(outletNode.textContent).toContain('Pizza');
402+
expect(outletNode.textContent).toContain('Chocolate');
388403

389404
host.detach();
390405

391-
expect(someDomElement.innerHTML).toBe('');
406+
expect(outletNode.innerHTML).toBe('');
392407
});
393408

394409
it('should attach and detach a template portal', () => {
@@ -397,7 +412,7 @@ describe('Portals', () => {
397412

398413
fixture.componentInstance.cakePortal.attach(host);
399414

400-
expect(someDomElement.textContent).toContain('Cake');
415+
expect(outletNode.textContent).toContain('Cake');
401416
});
402417

403418
it('should render a template portal with an inner template', () => {
@@ -406,7 +421,7 @@ describe('Portals', () => {
406421

407422
fixture.componentInstance.portalWithTemplate.attach(host);
408423

409-
expect(someDomElement.textContent).toContain('Durian');
424+
expect(outletNode.textContent).toContain('Durian');
410425
});
411426

412427
it('should attach and detach a template portal with a binding', () => {
@@ -426,17 +441,17 @@ describe('Portals', () => {
426441
fixture.detectChanges();
427442

428443
// Expect that the content of the attached portal is present.
429-
expect(someDomElement.textContent).toContain('Banana - fresh');
444+
expect(outletNode.textContent).toContain('Banana - fresh');
430445

431446
// When updating the binding value.
432447
testAppComponent.fruit = 'Mango';
433448
fixture.detectChanges();
434449

435450
// Expect the new value to be reflected in the rendered output.
436-
expect(someDomElement.textContent).toContain('Mango');
451+
expect(outletNode.textContent).toContain('Mango');
437452

438453
host.detach();
439-
expect(someDomElement.innerHTML).toBe('');
454+
expect(outletNode.innerHTML).toBe('');
440455
});
441456

442457
it('should change the attached portal', () => {
@@ -448,12 +463,12 @@ describe('Portals', () => {
448463

449464
appFixture.componentInstance.piePortal.attach(host);
450465

451-
expect(someDomElement.textContent).toContain('Pie');
466+
expect(outletNode.textContent).toContain('Pie');
452467

453468
host.detach();
454469
host.attach(new ComponentPortal(PizzaMsg, someViewContainerRef));
455470

456-
expect(someDomElement.textContent).toContain('Pizza');
471+
expect(outletNode.textContent).toContain('Pizza');
457472
});
458473

459474
it('should attach and detach a component portal without a ViewContainerRef', () => {
@@ -463,17 +478,17 @@ describe('Portals', () => {
463478

464479
expect(componentInstance instanceof PizzaMsg)
465480
.toBe(true, 'Expected a PizzaMsg component to be created');
466-
expect(someDomElement.textContent)
481+
expect(outletNode.textContent)
467482
.toContain('Pizza', 'Expected the static string "Pizza" in the DomPortalOutlet.');
468483

469484
componentInstance.snack = new Chocolate();
470485
someFixture.detectChanges();
471-
expect(someDomElement.textContent)
486+
expect(outletNode.textContent)
472487
.toContain('Chocolate', 'Expected the bound string "Chocolate" in the DomPortalOutlet');
473488

474489
host.detach();
475490

476-
expect(someDomElement.innerHTML)
491+
expect(outletNode.innerHTML)
477492
.toBe('', 'Expected the DomPortalOutlet to be empty after detach');
478493
});
479494

@@ -529,12 +544,38 @@ class PizzaMsg {
529544
constructor(@Optional() public snack: Chocolate) { }
530545
}
531546

547+
/**
548+
* Saves the parent node that the directive was attached to on init.
549+
* Useful to see where the element was in the DOM when it was first attached.
550+
*/
551+
@Directive({
552+
selector: '[savesParentNodeOnInit]'
553+
})
554+
class SaveParentNodeOnInit implements AfterViewInit {
555+
parentOnViewInit: HTMLElement;
556+
557+
constructor(private _elementRef: ElementRef<HTMLElement>) {}
558+
559+
ngAfterViewInit() {
560+
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
561+
}
562+
}
563+
532564
/** Simple component to grab an arbitrary ViewContainerRef */
533565
@Component({
534566
selector: 'some-placeholder',
535-
template: '<p>Hello</p>'
567+
template: `
568+
<p>Hello</p>
569+
570+
<ng-template #template>
571+
<div savesParentNodeOnInit></div>
572+
</ng-template>
573+
`
536574
})
537575
class ArbitraryViewContainerRefComponent {
576+
@ViewChild('template', {static: false}) template: TemplateRef<any>;
577+
@ViewChild(SaveParentNodeOnInit, {static: false}) saveParentNodeOnInit: SaveParentNodeOnInit;
578+
538579
constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
539580
}
540581

@@ -615,7 +656,7 @@ const TEST_COMPONENTS = [
615656
@NgModule({
616657
imports: [CommonModule, PortalModule],
617658
exports: TEST_COMPONENTS,
618-
declarations: TEST_COMPONENTS,
659+
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
619660
entryComponents: TEST_COMPONENTS,
620661
})
621662
class PortalTestModule { }

0 commit comments

Comments
 (0)