Skip to content

Commit 9dd9c2c

Browse files
committed
perf(expansion-panel): render content lazily
Currently the expansion panel renders all of its content on load which can be expensive, considering that all of it won't be visible. These changes switch the component to rendering only while the panel is open or animating. Fixes #8230.
1 parent 24f0471 commit 9dd9c2c

File tree

5 files changed

+107
-8
lines changed

5 files changed

+107
-8
lines changed

src/lib/expansion/expansion-module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {NgModule} from '@angular/core';
1111
import {UNIQUE_SELECTION_DISPATCHER_PROVIDER} from '@angular/cdk/collections';
1212
import {CdkAccordionModule} from '@angular/cdk/accordion';
1313
import {A11yModule} from '@angular/cdk/a11y';
14+
import {PortalModule} from '@angular/cdk/portal';
1415
import {MatAccordion} from './accordion';
1516
import {
1617
MatExpansionPanel,
@@ -25,7 +26,7 @@ import {
2526

2627

2728
@NgModule({
28-
imports: [CommonModule, A11yModule, CdkAccordionModule],
29+
imports: [CommonModule, A11yModule, CdkAccordionModule, PortalModule],
2930
exports: [
3031
MatAccordion,
3132
MatExpansionPanel,
Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
<ng-content select="mat-expansion-panel-header"></ng-content>
2-
<div [class.mat-expanded]="expanded" class="mat-expansion-panel-content"
3-
[@bodyExpansion]="_getExpandedState()" [id]="id">
2+
<div class="mat-expansion-panel-content"
3+
[class.mat-expanded]="expanded"
4+
[@bodyExpansion]="_getExpandedState()"
5+
(@bodyExpansion.done)="_animationDone.next()"
6+
[id]="id">
47
<div class="mat-expansion-panel-body">
5-
<ng-content></ng-content>
8+
<ng-template><ng-content></ng-content></ng-template>
9+
<ng-template cdkPortalOutlet></ng-template>
610
</div>
711
<ng-content select="mat-action-row"></ng-content>
812
</div>

src/lib/expansion/expansion-panel.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ import {
2020
Optional,
2121
SimpleChanges,
2222
ViewEncapsulation,
23+
TemplateRef,
24+
ViewChild,
25+
ViewContainerRef,
26+
AfterViewInit,
2327
} from '@angular/core';
2428
import {CdkAccordionItem} from '@angular/cdk/accordion';
2529
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
2630
import {CanDisable, mixinDisabled} from '@angular/material/core';
31+
import {CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal';
2732
import {Subject} from 'rxjs/Subject';
33+
import {take} from 'rxjs/operators/take';
34+
import {startWith} from 'rxjs/operators/startWith';
2835
import {MatAccordion} from './accordion';
2936

3037
/** Workaround for https://github.com/angular/angular/issues/17849 */
@@ -89,19 +96,32 @@ export type MatExpansionPanelState = 'expanded' | 'collapsed';
8996
],
9097
})
9198
export class MatExpansionPanel extends _MatExpansionPanelMixinBase
92-
implements CanDisable, OnChanges, OnDestroy {
99+
implements CanDisable, AfterViewInit, OnChanges, OnDestroy {
93100
/** Whether the toggle indicator should be hidden. */
94101
@Input() hideToggle: boolean = false;
95102

96103
/** Stream that emits for changes in `@Input` properties. */
97104
_inputChanges = new Subject<SimpleChanges>();
98105

106+
/** Stream that emits whenever an animation is complete. */
107+
_animationDone = new Subject<void>();
108+
99109
/** Optionally defined accordion the expansion panel belongs to. */
100110
accordion: MatAccordion;
101111

112+
/** `<ng-template>` that holds the user's content. */
113+
@ViewChild(TemplateRef) _content: TemplateRef<any>;
114+
115+
/** Outlet into which the user's content will be projected. */
116+
@ViewChild(CdkPortalOutlet) _outlet: CdkPortalOutlet;
117+
118+
/** Portal holding the user's content. */
119+
private _portal: TemplatePortal<any>;
120+
102121
constructor(@Optional() @Host() accordion: MatAccordion,
103122
_changeDetectorRef: ChangeDetectorRef,
104-
_uniqueSelectionDispatcher: UniqueSelectionDispatcher) {
123+
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
124+
private _viewContainerRef: ViewContainerRef) {
105125
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
106126
this.accordion = accordion;
107127
}
@@ -127,12 +147,31 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
127147
return this.expanded ? 'expanded' : 'collapsed';
128148
}
129149

150+
ngAfterViewInit() {
151+
this._portal = new TemplatePortal<any>(this._content, this._viewContainerRef);
152+
153+
// Render the content as soon as the panel becomes open.
154+
this.opened.pipe(startWith(null!)).subscribe(() => {
155+
if (this.expanded && !this._outlet.hasAttached()) {
156+
this._outlet.attach(this._portal);
157+
}
158+
});
159+
160+
// Remove the content once the panel is closed and the closing animation is done.
161+
this.closed.subscribe(() => {
162+
if (this._outlet.hasAttached()) {
163+
this._animationDone.pipe(take(1)).subscribe(() => this._outlet.detach());
164+
}
165+
});
166+
}
167+
130168
ngOnChanges(changes: SimpleChanges) {
131169
this._inputChanges.next(changes);
132170
}
133171

134172
ngOnDestroy() {
135173
this._inputChanges.complete();
174+
this._animationDone.complete();
136175
}
137176
}
138177

src/lib/expansion/expansion.spec.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ describe('MatExpansionPanel', () => {
1414
],
1515
declarations: [
1616
PanelWithContent,
17-
PanelWithCustomMargin
17+
PanelWithCustomMargin,
18+
PanelOpenOnLoad,
1819
],
1920
});
2021
TestBed.compileComponents();
@@ -34,6 +35,48 @@ describe('MatExpansionPanel', () => {
3435
expect(contentEl.classes['mat-expanded']).toBeTruthy();
3536
});
3637

38+
it('should render panel content lazily', fakeAsync(() => {
39+
let fixture = TestBed.createComponent(PanelWithContent);
40+
let content = fixture.debugElement.query(By.css('.mat-expansion-panel-content')).nativeElement;
41+
fixture.detectChanges();
42+
43+
expect(content.innerText.trim()).toBe('', 'Expected content element to be empty.');
44+
45+
fixture.componentInstance.expanded = true;
46+
fixture.detectChanges();
47+
48+
expect(content.innerText.trim()).toContain('Some content', 'Expected content to be rendered.');
49+
}));
50+
51+
it('should remove the panel content once the close animation is done', fakeAsync(() => {
52+
let fixture = TestBed.createComponent(PanelWithContent);
53+
let content = fixture.debugElement.query(By.css('.mat-expansion-panel-content')).nativeElement;
54+
55+
fixture.componentInstance.expanded = true;
56+
fixture.detectChanges();
57+
tick(250);
58+
59+
expect(content.innerText).toContain('Some content', 'Expected content to be rendered.');
60+
61+
fixture.componentInstance.expanded = false;
62+
fixture.detectChanges();
63+
64+
expect(content.innerText).toContain('Some content', 'Expected content to still be visible.');
65+
66+
tick(250);
67+
68+
expect(content.innerText.trim())
69+
.toBe('', 'Expected content to be removed once the animation is done.');
70+
}));
71+
72+
it('should render the content for a panel that is opened on init', fakeAsync(() => {
73+
let fixture = TestBed.createComponent(PanelOpenOnLoad);
74+
let content = fixture.debugElement.query(By.css('.mat-expansion-panel-content')).nativeElement;
75+
fixture.detectChanges();
76+
77+
expect(content.innerText.trim()).toContain('Some content', 'Expected content to be rendered.');
78+
}));
79+
3780
it('emit correct events for change in panel expanded state', () => {
3881
const fixture = TestBed.createComponent(PanelWithContent);
3982
fixture.componentInstance.expanded = true;
@@ -60,12 +103,13 @@ describe('MatExpansionPanel', () => {
60103

61104
it('should not be able to focus content while closed', fakeAsync(() => {
62105
const fixture = TestBed.createComponent(PanelWithContent);
63-
const button = fixture.debugElement.query(By.css('button')).nativeElement;
64106

65107
fixture.componentInstance.expanded = true;
66108
fixture.detectChanges();
67109
tick(250);
68110

111+
const button = fixture.debugElement.query(By.css('button')).nativeElement;
112+
69113
button.focus();
70114
expect(document.activeElement).toBe(button, 'Expected button to start off focusable.');
71115

@@ -237,3 +281,13 @@ class PanelWithContent {
237281
class PanelWithCustomMargin {
238282
expanded: boolean = false;
239283
}
284+
285+
286+
@Component({
287+
template: `
288+
<mat-expansion-panel [expanded]="true">
289+
<mat-expansion-panel-header>Panel Title</mat-expansion-panel-header>
290+
<p>Some content</p>
291+
</mat-expansion-panel>`
292+
})
293+
class PanelOpenOnLoad {}

tools/package-tools/rollup-globals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const rollupGlobals = {
8484
'rxjs/operators/share': 'Rx.Observable',
8585
'rxjs/operators/delay': 'Rx.Observable',
8686
'rxjs/operators/combineLatest': 'Rx.Observable',
87+
'rxjs/operators/take': 'Rx.Observable',
8788

8889
'rxjs/add/observable/merge': 'Rx.Observable',
8990
'rxjs/add/observable/fromEvent': 'Rx.Observable',

0 commit comments

Comments
 (0)