Skip to content

fix(portal): running change detection before nodes have been moved to outlet #16407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cdk/a11y/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ ng_test_library(
"//src/cdk/keycodes",
"//src/cdk/observers",
"//src/cdk/platform",
"//src/cdk/portal",
"//src/cdk/testing/private",
"@npm//@angular/platform-browser",
"@npm//rxjs",
Expand Down
48 changes: 46 additions & 2 deletions src/cdk/a11y/focus-trap/focus-trap.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import {Platform} from '@angular/cdk/platform';
import {Component, ViewChild} from '@angular/core';
import {Component, ViewChild, TemplateRef, ViewContainerRef} from '@angular/core';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {PortalModule, CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
import {A11yModule, FocusTrap, CdkTrapFocus} from '../index';


describe('FocusTrap', () => {

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [A11yModule],
imports: [A11yModule, PortalModule],
declarations: [
FocusTrapWithBindings,
SimpleFocusTrap,
Expand All @@ -17,6 +18,7 @@ describe('FocusTrap', () => {
FocusTrapWithoutFocusableElements,
FocusTrapWithAutoCapture,
FocusTrapUnfocusableTarget,
FocusTrapInsidePortal,
],
});

Expand Down Expand Up @@ -187,6 +189,27 @@ describe('FocusTrap', () => {
});
}));
});

it('should put anchors inside the outlet when set at the root of a template portal', () => {
const fixture = TestBed.createComponent(FocusTrapInsidePortal);
const instance = fixture.componentInstance;
fixture.detectChanges();
const outlet: HTMLElement = fixture.nativeElement.querySelector('.portal-outlet');

expect(outlet.querySelectorAll('button').length)
.toBe(0, 'Expected no buttons inside the outlet on init.');
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
.toBe(0, 'Expected no focus trap anchors inside the outlet on init.');

const portal = new TemplatePortal(instance.template, instance.viewContainerRef);
instance.portalOutlet.attachTemplatePortal(portal);
fixture.detectChanges();

expect(outlet.querySelectorAll('button').length)
.toBe(1, 'Expected one button inside the outlet after attaching.');
expect(outlet.querySelectorAll('.cdk-focus-trap-anchor').length)
.toBe(2, 'Expected two focus trap anchors in the outlet after attaching.');
});
});


Expand Down Expand Up @@ -283,3 +306,24 @@ class FocusTrapWithSvg {
class FocusTrapWithoutFocusableElements {
@ViewChild(CdkTrapFocus) focusTrapDirective: CdkTrapFocus;
}


@Component({
template: `
<div class="portal-outlet">
<ng-template cdkPortalOutlet></ng-template>
</div>

<ng-template #template>
<div cdkTrapFocus>
<button>Click me</button>
</div>
</ng-template>
`,
})
class FocusTrapInsidePortal {
@ViewChild('template', {static: false}) template: TemplateRef<any>;
@ViewChild(CdkPortalOutlet, {static: false}) portalOutlet: CdkPortalOutlet;

constructor(public viewContainerRef: ViewContainerRef) {}
}
6 changes: 5 additions & 1 deletion src/cdk/portal/dom-portal-outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,18 @@ export class DomPortalOutlet extends BasePortalOutlet {
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
let viewContainer = portal.viewContainerRef;
let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context);
viewRef.detectChanges();

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

// Note that we want to detect changes after the nodes have been moved so that
// any directives inside the portal that are looking at the DOM inside a lifecycle
// hook won't be invoked too early.
viewRef.detectChanges();

this.setDisposeFn((() => {
let index = viewContainer.indexOf(viewRef);
if (index !== -1) {
Expand Down
47 changes: 44 additions & 3 deletions src/cdk/portal/portal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
ViewChild,
ViewChildren,
ViewContainerRef,
Directive,
AfterViewInit,
} from '@angular/core';
import {ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {DomPortalOutlet} from './dom-portal-outlet';
Expand Down Expand Up @@ -404,7 +406,7 @@ describe('Portals', () => {
let componentFactoryResolver: ComponentFactoryResolver;
let someViewContainerRef: ViewContainerRef;
let someInjector: Injector;
let someFixture: ComponentFixture<any>;
let someFixture: ComponentFixture<ArbitraryViewContainerRefComponent>;
let someDomElement: HTMLElement;
let host: DomPortalOutlet;
let injector: Injector;
Expand Down Expand Up @@ -440,6 +442,19 @@ describe('Portals', () => {
expect(someDomElement.innerHTML).toBe('');
});

it('should move the DOM nodes before running change detection', () => {
someFixture.detectChanges();
let portal = new TemplatePortal(someFixture.componentInstance.template, someViewContainerRef);

host.attachTemplatePortal(portal);
someFixture.detectChanges();

expect(someFixture.componentInstance.saveParentNodeOnInit.parentOnViewInit)
.toBe(someDomElement);

host.dispose();
});

it('should attach and detach a component portal with a given injector', () => {
let fixture = TestBed.createComponent(ArbitraryViewContainerRefComponent);
someViewContainerRef = fixture.componentInstance.viewContainerRef;
Expand Down Expand Up @@ -634,12 +649,38 @@ class PizzaMsg {
constructor(@Optional() public snack: Chocolate) { }
}

/**
* Saves the parent node that the directive was attached to on init.
* Useful to see where the element was in the DOM when it was first attached.
*/
@Directive({
selector: '[savesParentNodeOnInit]'
})
class SaveParentNodeOnInit implements AfterViewInit {
parentOnViewInit: HTMLElement;

constructor(private _elementRef: ElementRef<HTMLElement>) {}

ngAfterViewInit() {
this.parentOnViewInit = this._elementRef.nativeElement.parentElement!;
}
}

/** Simple component to grab an arbitrary ViewContainerRef */
@Component({
selector: 'some-placeholder',
template: '<p>Hello</p>'
template: `
<p>Hello</p>

<ng-template #template>
<div savesParentNodeOnInit></div>
</ng-template>
`
})
class ArbitraryViewContainerRefComponent {
@ViewChild('template', {static: false}) template: TemplateRef<any>;
@ViewChild(SaveParentNodeOnInit, {static: false}) saveParentNodeOnInit: SaveParentNodeOnInit;

constructor(public viewContainerRef: ViewContainerRef, public injector: Injector) { }
}

Expand Down Expand Up @@ -731,7 +772,7 @@ const TEST_COMPONENTS = [
@NgModule({
imports: [CommonModule, PortalModule],
exports: TEST_COMPONENTS,
declarations: TEST_COMPONENTS,
declarations: [...TEST_COMPONENTS, SaveParentNodeOnInit],
entryComponents: TEST_COMPONENTS,
})
class PortalTestModule { }