Skip to content

Commit b488b39

Browse files
crisbetommalerba
authored andcommitted
fix(portal): inaccurate hasAttahed result and portal being cleared if attached too early (#8642)
Fixes the `hasAttached` method always returning false when the portal was attached using `CdkPortalOutlet.attachComponentPortal` or `CdkPortalOutlet.attachTemplatePortal`. This means that checks like https://github.com/angular/material2/blob/master/src/lib/dialog/dialog-container.ts#L118 always evaluate to false. Fixing `hasAttached` revealed another issue: if a portal outlet is unbound (e.g. `<div cdkPortalOutlet>`) and the consumer attaches something to it on the before the first CD round, the content will be cleared immediately due to Angular invoking the getter with an empty string. This has been fixed by not clearing any previously-attached portals if the reset value is set before `ngOnInit`. Fixes #8628.
1 parent b8664b8 commit b488b39

File tree

3 files changed

+118
-50
lines changed

3 files changed

+118
-50
lines changed

src/cdk/portal/portal-directives.ts

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@
77
*/
88

99
import {
10-
NgModule,
11-
ComponentRef,
12-
Directive,
13-
EmbeddedViewRef,
14-
TemplateRef,
15-
ComponentFactoryResolver,
16-
ViewContainerRef,
17-
OnDestroy,
18-
Input,
10+
NgModule,
11+
ComponentRef,
12+
Directive,
13+
EmbeddedViewRef,
14+
TemplateRef,
15+
ComponentFactoryResolver,
16+
ViewContainerRef,
17+
OnDestroy,
18+
OnInit,
19+
Input,
1920
} from '@angular/core';
2021
import {Portal, TemplatePortal, ComponentPortal, BasePortalOutlet} from './portal';
2122

@@ -47,9 +48,9 @@ export class CdkPortal extends TemplatePortal<any> {
4748
exportAs: 'cdkPortalOutlet, cdkPortalHost',
4849
inputs: ['portal: cdkPortalOutlet']
4950
})
50-
export class CdkPortalOutlet extends BasePortalOutlet implements OnDestroy {
51-
/** The attached portal. */
52-
private _portal: Portal<any> | null = null;
51+
export class CdkPortalOutlet extends BasePortalOutlet implements OnInit, OnDestroy {
52+
/** Whether the portal component is initialized. */
53+
private _isInitialized = false;
5354

5455
constructor(
5556
private _componentFactoryResolver: ComponentFactoryResolver,
@@ -69,10 +70,18 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnDestroy {
6970

7071
/** Portal associated with the Portal outlet. */
7172
get portal(): Portal<any> | null {
72-
return this._portal;
73+
return this._attachedPortal;
7374
}
7475

7576
set portal(portal: Portal<any> | null) {
77+
// Ignore the cases where the `portal` is set to a falsy value before the lifecycle hooks have
78+
// run. This handles the cases where the user might do something like `<div cdkPortalOutlet>`
79+
// and attach a portal programmatically in the parent component. When Angular does the first CD
80+
// round, it will fire the setter with empty string, causing the user's content to be cleared.
81+
if (this.hasAttached() && !portal && !this._isInitialized) {
82+
return;
83+
}
84+
7685
if (this.hasAttached()) {
7786
super.detach();
7887
}
@@ -81,12 +90,16 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnDestroy {
8190
super.attach(portal);
8291
}
8392

84-
this._portal = portal;
93+
this._attachedPortal = portal;
94+
}
95+
96+
ngOnInit() {
97+
this._isInitialized = true;
8598
}
8699

87100
ngOnDestroy() {
88101
super.dispose();
89-
this._portal = null;
102+
this._attachedPortal = null;
90103
}
91104

92105
/**
@@ -100,18 +113,18 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnDestroy {
100113

101114
// If the portal specifies an origin, use that as the logical location of the component
102115
// in the application tree. Otherwise use the location of this PortalOutlet.
103-
let viewContainerRef = portal.viewContainerRef != null ?
116+
const viewContainerRef = portal.viewContainerRef != null ?
104117
portal.viewContainerRef :
105118
this._viewContainerRef;
106119

107-
let componentFactory =
120+
const componentFactory =
108121
this._componentFactoryResolver.resolveComponentFactory(portal.component);
109-
let ref = viewContainerRef.createComponent(
122+
const ref = viewContainerRef.createComponent(
110123
componentFactory, viewContainerRef.length,
111124
portal.injector || viewContainerRef.parentInjector);
112125

113126
super.setDisposeFn(() => ref.destroy());
114-
this._portal = portal;
127+
this._attachedPortal = portal;
115128

116129
return ref;
117130
}
@@ -126,7 +139,7 @@ export class CdkPortalOutlet extends BasePortalOutlet implements OnDestroy {
126139
const viewRef = this._viewContainerRef.createEmbeddedView(portal.templateRef, portal.context);
127140
super.setDisposeFn(() => this._viewContainerRef.clear());
128141

129-
this._portal = portal;
142+
this._attachedPortal = portal;
130143

131144
return viewRef;
132145
}

src/cdk/portal/portal.spec.ts

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {inject, ComponentFixture, TestBed, async} from '@angular/core/testing';
1+
import {inject, ComponentFixture, TestBed} from '@angular/core/testing';
22
import {
33
NgModule,
44
Component,
@@ -20,13 +20,11 @@ import {DomPortalOutlet} from './dom-portal-outlet';
2020

2121
describe('Portals', () => {
2222

23-
beforeEach(async(() => {
24-
TestBed.configureTestingModule({
25-
imports: [PortalModule, PortalTestModule],
26-
});
27-
28-
TestBed.compileComponents();
29-
}));
23+
beforeEach(() => {
24+
TestBed
25+
.configureTestingModule({imports: [PortalModule, PortalTestModule]})
26+
.compileComponents();
27+
});
3028

3129
describe('CdkPortalOutlet', () => {
3230
let fixture: ComponentFixture<PortalTestApp>;
@@ -37,28 +35,33 @@ describe('Portals', () => {
3735

3836
it('should load a component into the portal', () => {
3937
// Set the selectedHost to be a ComponentPortal.
40-
let testAppComponent = fixture.debugElement.componentInstance;
41-
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
38+
let testAppComponent = fixture.componentInstance;
39+
let componentPortal = new ComponentPortal(PizzaMsg);
40+
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
41+
42+
testAppComponent.selectedPortal = componentPortal;
4243
fixture.detectChanges();
4344

4445
// Expect that the content of the attached portal is present.
45-
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
4646
expect(hostContainer.textContent).toContain('Pizza');
47+
expect(testAppComponent.portalOutlet.portal).toBe(componentPortal);
4748
});
4849

4950
it('should load a template into the portal', () => {
50-
let testAppComponent = fixture.debugElement.componentInstance;
51+
let testAppComponent = fixture.componentInstance;
5152
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
52-
5353
let templatePortal = new TemplatePortal(testAppComponent.templateRef, null!);
54+
5455
testAppComponent.selectedPortal = templatePortal;
5556
fixture.detectChanges();
57+
5658
// Expect that the content of the attached portal is present and no context is projected
5759
expect(hostContainer.textContent).toContain('Banana');
60+
expect(testAppComponent.portalOutlet.portal).toBe(templatePortal);
5861
});
5962

6063
it('should project template context bindings in the portal', () => {
61-
let testAppComponent = fixture.debugElement.componentInstance;
64+
let testAppComponent = fixture.componentInstance;
6265
let hostContainer = fixture.nativeElement.querySelector('.portal-container');
6366

6467
// TemplatePortal without context:
@@ -99,7 +102,7 @@ describe('Portals', () => {
99102

100103
it('should dispose the host when destroyed', () => {
101104
// Set the selectedHost to be a ComponentPortal.
102-
let testAppComponent = fixture.debugElement.componentInstance;
105+
let testAppComponent = fixture.componentInstance;
103106
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
104107

105108
fixture.detectChanges();
@@ -114,7 +117,7 @@ describe('Portals', () => {
114117
let chocolateInjector = new ChocolateInjector(fixture.componentInstance.injector);
115118

116119
// Set the selectedHost to be a ComponentPortal.
117-
let testAppComponent = fixture.debugElement.componentInstance;
120+
let testAppComponent = fixture.componentInstance;
118121
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg, undefined, chocolateInjector);
119122
fixture.detectChanges();
120123

@@ -125,7 +128,7 @@ describe('Portals', () => {
125128
});
126129

127130
it('should load a <ng-template> portal', () => {
128-
let testAppComponent = fixture.debugElement.componentInstance;
131+
let testAppComponent = fixture.componentInstance;
129132

130133
// Detect changes initially so that the component's ViewChildren are resolved.
131134
fixture.detectChanges();
@@ -140,7 +143,7 @@ describe('Portals', () => {
140143
});
141144

142145
it('should load a <ng-template> portal with the `*` sugar', () => {
143-
let testAppComponent = fixture.debugElement.componentInstance;
146+
let testAppComponent = fixture.componentInstance;
144147

145148
// Detect changes initially so that the component's ViewChildren are resolved.
146149
fixture.detectChanges();
@@ -155,7 +158,7 @@ describe('Portals', () => {
155158
});
156159

157160
it('should load a <ng-template> portal with a binding', () => {
158-
let testAppComponent = fixture.debugElement.componentInstance;
161+
let testAppComponent = fixture.componentInstance;
159162

160163
// Detect changes initially so that the component's ViewChildren are resolved.
161164
fixture.detectChanges();
@@ -177,7 +180,7 @@ describe('Portals', () => {
177180
});
178181

179182
it('should load a <ng-template> portal with an inner template', () => {
180-
let testAppComponent = fixture.debugElement.componentInstance;
183+
let testAppComponent = fixture.componentInstance;
181184

182185
// Detect changes initially so that the component's ViewChildren are resolved.
183186
fixture.detectChanges();
@@ -199,7 +202,7 @@ describe('Portals', () => {
199202
});
200203

201204
it('should change the attached portal', () => {
202-
let testAppComponent = fixture.debugElement.componentInstance;
205+
let testAppComponent = fixture.componentInstance;
203206

204207
// Detect changes initially so that the component's ViewChildren are resolved.
205208
fixture.detectChanges();
@@ -219,22 +222,22 @@ describe('Portals', () => {
219222
});
220223

221224
it('should detach the portal when it is set to null', () => {
222-
let testAppComponent = fixture.debugElement.componentInstance;
225+
let testAppComponent = fixture.componentInstance;
223226
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
224227

225228
fixture.detectChanges();
226229
expect(testAppComponent.portalOutlet.hasAttached()).toBe(true);
227230
expect(testAppComponent.portalOutlet.portal).toBe(testAppComponent.selectedPortal);
228231

229-
testAppComponent.selectedPortal = null;
232+
testAppComponent.selectedPortal = null!;
230233
fixture.detectChanges();
231234

232235
expect(testAppComponent.portalOutlet.hasAttached()).toBe(false);
233236
expect(testAppComponent.portalOutlet.portal).toBeNull();
234237
});
235238

236239
it('should set the `portal` when attaching a component portal programmatically', () => {
237-
let testAppComponent = fixture.debugElement.componentInstance;
240+
let testAppComponent = fixture.componentInstance;
238241
let portal = new ComponentPortal(PizzaMsg);
239242

240243
testAppComponent.portalOutlet.attachComponentPortal(portal);
@@ -243,7 +246,7 @@ describe('Portals', () => {
243246
});
244247

245248
it('should set the `portal` when attaching a template portal programmatically', () => {
246-
let testAppComponent = fixture.debugElement.componentInstance;
249+
let testAppComponent = fixture.componentInstance;
247250
fixture.detectChanges();
248251

249252
testAppComponent.portalOutlet.attachTemplatePortal(testAppComponent.cakePortal);
@@ -252,7 +255,7 @@ describe('Portals', () => {
252255
});
253256

254257
it('should clear the portal reference on destroy', () => {
255-
let testAppComponent = fixture.debugElement.componentInstance;
258+
let testAppComponent = fixture.componentInstance;
256259

257260
testAppComponent.selectedPortal = new ComponentPortal(PizzaMsg);
258261
fixture.detectChanges();
@@ -263,6 +266,40 @@ describe('Portals', () => {
263266

264267
expect(testAppComponent.portalOutlet.portal).toBeNull();
265268
});
269+
270+
it('should not clear programmatically-attached portals on init', () => {
271+
fixture.destroy();
272+
273+
const unboundFixture = TestBed.createComponent(UnboundPortalTestApp);
274+
275+
// Note: calling `detectChanges` here will cause a false positive.
276+
// What we're testing is attaching before the first CD cycle.
277+
unboundFixture.componentInstance.portalOutlet.attach(new ComponentPortal(PizzaMsg));
278+
unboundFixture.detectChanges();
279+
280+
expect(unboundFixture.nativeElement.querySelector('.portal-container').textContent)
281+
.toContain('Pizza');
282+
});
283+
284+
it('should be considered attached when attaching using `attach`', () => {
285+
expect(fixture.componentInstance.portalOutlet.hasAttached()).toBe(false);
286+
fixture.componentInstance.portalOutlet.attach(new ComponentPortal(PizzaMsg));
287+
expect(fixture.componentInstance.portalOutlet.hasAttached()).toBe(true);
288+
});
289+
290+
it('should be considered attached when attaching using `attachComponentPortal`', () => {
291+
expect(fixture.componentInstance.portalOutlet.hasAttached()).toBe(false);
292+
fixture.componentInstance.portalOutlet.attachComponentPortal(new ComponentPortal(PizzaMsg));
293+
expect(fixture.componentInstance.portalOutlet.hasAttached()).toBe(true);
294+
});
295+
296+
it('should be considered attached when attaching using `attachTemplatePortal`', () => {
297+
const instance = fixture.componentInstance;
298+
expect(instance.portalOutlet.hasAttached()).toBe(false);
299+
instance.portalOutlet.attachTemplatePortal(new TemplatePortal(instance.templateRef, null!));
300+
expect(instance.portalOutlet.hasAttached()).toBe(true);
301+
});
302+
266303
});
267304

268305
describe('DomPortalOutlet', () => {
@@ -345,7 +382,7 @@ describe('Portals', () => {
345382
it('should attach and detach a template portal with a binding', () => {
346383
let fixture = TestBed.createComponent(PortalTestApp);
347384

348-
let testAppComponent = fixture.debugElement.componentInstance;
385+
let testAppComponent = fixture.componentInstance;
349386

350387
// Detect changes initially so that the component's ViewChildren are resolved.
351388
fixture.detectChanges();
@@ -484,7 +521,7 @@ class PortalTestApp {
484521
@ViewChild(CdkPortalOutlet) portalOutlet: CdkPortalOutlet;
485522
@ViewChild('templateRef', { read: TemplateRef }) templateRef: TemplateRef<any>;
486523

487-
selectedPortal: Portal<any>;
524+
selectedPortal: Portal<any>|undefined;
488525
fruit: string = 'Banana';
489526
fruits = ['Apple', 'Pineapple', 'Durian'];
490527

@@ -508,9 +545,27 @@ class PortalTestApp {
508545

509546
}
510547

548+
/** Test-bed component that contains a portal outlet and a couple of template portals. */
549+
@Component({
550+
template: `
551+
<div class="portal-container">
552+
<ng-template cdkPortalOutlet></ng-template>
553+
</div>
554+
`,
555+
})
556+
class UnboundPortalTestApp {
557+
@ViewChild(CdkPortalOutlet) portalOutlet: CdkPortalOutlet;
558+
}
559+
511560
// Create a real (non-test) NgModule as a workaround for
512561
// https://github.com/angular/angular/issues/10760
513-
const TEST_COMPONENTS = [PortalTestApp, ArbitraryViewContainerRefComponent, PizzaMsg];
562+
const TEST_COMPONENTS = [
563+
PortalTestApp,
564+
UnboundPortalTestApp,
565+
ArbitraryViewContainerRefComponent,
566+
PizzaMsg
567+
];
568+
514569
@NgModule({
515570
imports: [CommonModule, PortalModule],
516571
exports: TEST_COMPONENTS,

src/cdk/portal/portal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export interface PortalOutlet {
168168
*/
169169
export abstract class BasePortalOutlet implements PortalOutlet {
170170
/** The portal currently attached to the host. */
171-
private _attachedPortal: Portal<any> | null;
171+
protected _attachedPortal: Portal<any> | null;
172172

173173
/** A function that will permanently dispose this host. */
174174
private _disposeFn: (() => void) | null;

0 commit comments

Comments
 (0)