Skip to content

Commit 46bcb20

Browse files
authoredApr 27, 2021
perf: example rendering performance improvements (#961)
When generating an example in Stackblitz, we have to create a `form` element and submit it to a specific URL. The `form` has to be created eagerly, because some browsers will block us from submitting it if it is created asynchronously. As a result of this setup we fire off a lot of HTTP requests when an example is rendered which slows the page down a lot. These changes make the following improvements which shave off more than a second of scripting time when transitioning from the "Overview" to "Examples". I've used the datepicker examples as a benchmark. 1. Runs the HTTP requests outside of the Angular zone so that we don't trigger change detections once each request is resolved. 2. Caches the file content so that the user doesn't have to load the same file multiple times. I've also fixed that the copyright still said "2020".
1 parent 5513093 commit 46bcb20

File tree

3 files changed

+65
-50
lines changed

3 files changed

+65
-50
lines changed
 

‎material.angular.io/src/app/shared/stack-blitz/stack-blitz-button.ts

+19-17
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {StackBlitzWriter} from './stack-blitz-writer';
88
@Component({
99
selector: 'stack-blitz-button',
1010
templateUrl: './stack-blitz-button.html',
11-
providers: [StackBlitzWriter],
1211
})
1312
export class StackBlitzButton {
1413
/**
@@ -18,28 +17,31 @@ export class StackBlitzButton {
1817
* StackBlitz not yet being ready for people with poor network connections or slow devices.
1918
*/
2019
isDisabled = false;
21-
stackBlitzForm: HTMLFormElement | undefined;
2220
exampleData: ExampleData | undefined;
2321

24-
@HostListener('mouseover') onMouseOver() {
25-
this.isDisabled = !this.stackBlitzForm;
22+
/**
23+
* Form used to submit the data to Stackblitz.
24+
* Important! it needs to be constructed ahead-of-time, because doing so on-demand
25+
* will cause Firefox to block the submit as a popup, because it didn't happen within
26+
* the same tick as the user interaction.
27+
*/
28+
private _stackBlitzForm: HTMLFormElement | undefined;
29+
30+
@HostListener('mouseover')
31+
onMouseOver() {
32+
this.isDisabled = !this._stackBlitzForm;
2633
}
2734

2835
@Input()
2936
set example(example: string | undefined) {
3037
if (example) {
38+
const isTest = example.includes('harness');
3139
this.exampleData = new ExampleData(example);
32-
if (this.exampleData) {
33-
this.stackBlitzWriter.constructStackBlitzForm(example,
34-
this.exampleData,
35-
example.includes('harness'))
36-
.then((stackBlitzForm: HTMLFormElement) => {
37-
this.stackBlitzForm = stackBlitzForm;
40+
this.stackBlitzWriter.constructStackBlitzForm(example, this.exampleData, isTest)
41+
.then(form => {
42+
this._stackBlitzForm = form;
3843
this.isDisabled = false;
3944
});
40-
} else {
41-
this.isDisabled = true;
42-
}
4345
} else {
4446
this.isDisabled = true;
4547
}
@@ -52,10 +54,10 @@ export class StackBlitzButton {
5254
// to submit if it is detached from the document. See the following chromium commit for
5355
// more details:
5456
// https://chromium.googlesource.com/chromium/src/+/962c2a22ddc474255c776aefc7abeba00edc7470%5E!
55-
if (this.stackBlitzForm) {
56-
document.body.appendChild(this.stackBlitzForm);
57-
this.stackBlitzForm.submit();
58-
document.body.removeChild(this.stackBlitzForm);
57+
if (this._stackBlitzForm) {
58+
document.body.appendChild(this._stackBlitzForm);
59+
this._stackBlitzForm.submit();
60+
document.body.removeChild(this._stackBlitzForm);
5961
}
6062
}
6163
}

‎material.angular.io/src/app/shared/stack-blitz/stack-blitz-writer.spec.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@ describe('StackBlitzWriter', () => {
4141
});
4242

4343
it('should append correct copyright', () => {
44+
const year = new Date().getFullYear();
4445
expect(stackBlitzWriter._appendCopyright('test.ts', 'NoContent')).toBe(`NoContent
4546
46-
/** Copyright 2020 Google LLC. All Rights Reserved.
47+
/** Copyright ${year} Google LLC. All Rights Reserved.
4748
Use of this source code is governed by an MIT-style license that
4849
can be found in the LICENSE file at http://angular.io/license */`);
4950

5051
expect(stackBlitzWriter._appendCopyright('test.html', 'NoContent')).toBe(`NoContent
5152
52-
<!-- Copyright 2020 Google LLC. All Rights Reserved.
53+
<!-- Copyright ${year} Google LLC. All Rights Reserved.
5354
Use of this source code is governed by an MIT-style license that
5455
can be found in the LICENSE file at http://angular.io/license -->`);
5556

‎material.angular.io/src/app/shared/stack-blitz/stack-blitz-writer.ts

+43-31
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {HttpClient} from '@angular/common/http';
2-
import {Injectable} from '@angular/core';
2+
import {Injectable, NgZone} from '@angular/core';
33
import {VERSION} from '@angular/material/core';
44
import {EXAMPLE_COMPONENTS, ExampleData} from '@angular/components-examples';
5+
import {Observable} from 'rxjs';
6+
import {shareReplay, take} from 'rxjs/operators';
57

68
import {materialVersion} from '../version/version';
79

810
const STACKBLITZ_URL = 'https://run.stackblitz.com/api/angular/v1';
911

1012
const COPYRIGHT =
11-
`Copyright 2020 Google LLC. All Rights Reserved.
13+
`Copyright ${new Date().getFullYear()} Google LLC. All Rights Reserved.
1214
Use of this source code is governed by an MIT-style license that
1315
can be found in the LICENSE file at http://angular.io/license`;
1416

@@ -116,17 +118,18 @@ const testDependencies = {
116118
* dependencies: dependencies
117119
* }
118120
*/
119-
@Injectable()
121+
@Injectable({providedIn: 'root'})
120122
export class StackBlitzWriter {
121-
constructor(private _http: HttpClient) {}
123+
private _fileCache = new Map<string, Observable<string>>();
124+
125+
constructor(private _http: HttpClient, private _ngZone: NgZone) {}
122126

123127
/**
124128
* Returns an HTMLFormElement that will open a new StackBlitz template with the example data when
125129
* called with submit().
126130
*/
127-
constructStackBlitzForm(exampleId: string,
128-
data: ExampleData,
129-
isTest: boolean): Promise<HTMLFormElement> {
131+
async constructStackBlitzForm(exampleId: string, data: ExampleData,
132+
isTest: boolean): Promise<HTMLFormElement> {
130133
const liveExample = EXAMPLE_COMPONENTS[exampleId];
131134
const indexFile = `src%2Fapp%2F${data.indexFilename}`;
132135
const form = this._createFormElement(indexFile);
@@ -140,26 +143,30 @@ export class StackBlitzWriter {
140143
'dependencies',
141144
JSON.stringify(isTest ? testDependencies : dependencies));
142145

143-
return new Promise(resolve => {
144-
const templateContents = (isTest ? TEST_TEMPLATE_FILES : TEMPLATE_FILES)
145-
.map(file => this._readFile(form,
146-
data,
147-
file,
148-
isTest ? TEST_TEMPLATE_PATH : TEMPLATE_PATH,
149-
isTest));
146+
// Run outside the zone since this form doesn't interact with Angular
147+
// and the file requests can cause excessive change detections.
148+
await this._ngZone.runOutsideAngular(() => {
149+
const fileReadPromises: Promise<void>[] = [];
150+
151+
// Read all of the template files.
152+
(isTest ? TEST_TEMPLATE_FILES : TEMPLATE_FILES).forEach(file => fileReadPromises.push(
153+
this._loadAndAppendFile(form, data, file, isTest ? TEST_TEMPLATE_PATH : TEMPLATE_PATH,
154+
isTest)));
150155

151-
const exampleContents = data.exampleFiles
152-
.map(file => this._readFile(form, data, file, baseExamplePath, isTest));
156+
// Read the example-specific files.
157+
data.exampleFiles.forEach(file => fileReadPromises.push(this._loadAndAppendFile(form, data,
158+
file, baseExamplePath, isTest)));
153159

154160
// TODO(josephperrott): Prevent including assets to be manually checked.
155161
if (data.selectorName === 'icon-svg-example') {
156-
this._readFile(form, data, 'assets/img/examples/thumbup-icon.svg', '', isTest, false);
162+
fileReadPromises.push(this._loadAndAppendFile(form, data,
163+
'assets/img/examples/thumbup-icon.svg', '', isTest, false));
157164
}
158165

159-
Promise.all(templateContents.concat(exampleContents)).then(() => {
160-
resolve(form);
161-
});
166+
return Promise.all(fileReadPromises);
162167
});
168+
169+
return form;
163170
}
164171

165172
/** Constructs a new form element that will navigate to the StackBlitz url. */
@@ -172,7 +179,7 @@ export class StackBlitzWriter {
172179
}
173180

174181
/** Appends the name and value as an input to the form. */
175-
_appendFormInput(form: HTMLFormElement, name: string, value: string): void {
182+
private _appendFormInput(form: HTMLFormElement, name: string, value: string): void {
176183
const input = document.createElement('input');
177184
input.type = 'hidden';
178185
input.name = name;
@@ -189,13 +196,18 @@ export class StackBlitzWriter {
189196
* @param isTest whether file is part of a test example
190197
* @param prependApp whether to prepend the 'app' prefix to the path
191198
*/
192-
_readFile(form: HTMLFormElement,
193-
data: ExampleData,
194-
filename: string,
195-
path: string,
196-
isTest: boolean,
197-
prependApp = true): void {
198-
this._http.get(path + filename, {responseType: 'text'}).subscribe(
199+
private _loadAndAppendFile(form: HTMLFormElement, data: ExampleData, filename: string,
200+
path: string, isTest: boolean, prependApp = true): Promise<void> {
201+
const url = path + filename;
202+
let stream = this._fileCache.get(url);
203+
204+
if (!stream) {
205+
stream = this._http.get(url, {responseType: 'text'}).pipe(shareReplay(1));
206+
this._fileCache.set(url, stream);
207+
}
208+
209+
// The `take(1)` is necessary, because the Promise from `toPromise` resolves on complete.
210+
return stream.pipe(take(1)).toPromise().then(
199211
response => this._addFileToForm(form, data, response, filename, path, isTest, prependApp),
200212
error => console.log(error)
201213
);
@@ -232,9 +244,9 @@ export class StackBlitzWriter {
232244
* This will replace those placeholders with the names from the example metadata,
233245
* e.g. "<basic-button-example>" and "BasicButtonExample"
234246
*/
235-
_replaceExamplePlaceholderNames(data: ExampleData,
236-
fileName: string,
237-
fileContent: string): string {
247+
private _replaceExamplePlaceholderNames(data: ExampleData,
248+
fileName: string,
249+
fileContent: string): string {
238250
if (fileName === 'src/index.html') {
239251
// Replace the component selector in `index,html`.
240252
// For example, <material-docs-example></material-docs-example> will be replaced as

0 commit comments

Comments
 (0)
Please sign in to comment.