Skip to content

Commit 71b8963

Browse files
committedFeb 14, 2025
fix(material-angular-io): remove docs data from critical path
The docs site was loading the data of all the examples up-front through an eager import which was bringing in ~170kb of JavaScript which isn't necessary for the initial load. These changes refactor the various call sites so that they load the example data asynchronously.
1 parent 546babd commit 71b8963

15 files changed

+205
-310
lines changed
 

‎material.angular.io/.eslintrc.json

+2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
"@angular-eslint/component-selector": "off",
2121
"@angular-eslint/directive-class-suffix": "off",
2222
"@angular-eslint/directive-selector": "off",
23+
"@angular-eslint/no-host-metadata-property": "off",
2324
"@typescript-eslint/dot-notation": "off",
2425
"@typescript-eslint/member-delimiter-style": "off",
26+
"@typescript-eslint/naming-convention": "off",
2527
"@typescript-eslint/explicit-member-accessibility": [
2628
"off",
2729
{

‎material.angular.io/src/app/pages/component-category-list/component-category-list.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
focusOnNavigation>
44
<div [innerHTML]="_categoryListSummary"></div>
55
</div>
6-
@if ((params | async)?.section; as section) {
6+
@if (items.length > 0) {
77
<div class="docs-component-category-list">
8-
@for (component of docItems.getItems(section); track component) {
8+
@for (component of items; track component) {
99
<a class="docs-component-category-list-item"
1010
[routerLink]="'/' + section + '/' + component.id">
1111
<div class="docs-component-category-list-card" matRipple>

‎material.angular.io/src/app/pages/component-category-list/component-category-list.spec.ts

-41
This file was deleted.

‎material.angular.io/src/app/pages/component-category-list/component-category-list.ts

+16-14
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import {AsyncPipe} from '@angular/common';
21
import {Component, NgModule, OnDestroy, OnInit} from '@angular/core';
32
import {MatCardModule} from '@angular/material/card';
4-
import {ActivatedRoute, Params, RouterModule, RouterLink} from '@angular/router';
3+
import {ActivatedRoute, RouterModule, RouterLink} from '@angular/router';
54
import {MatRipple} from '@angular/material/core';
6-
import {combineLatest, Observable, Subscription} from 'rxjs';
5+
import {combineLatest, Subscription} from 'rxjs';
76

8-
import {DocumentationItems, SECTIONS} from '../../shared/documentation-items/documentation-items';
7+
import {
8+
DocItem,
9+
DocumentationItems,
10+
SECTIONS,
11+
} from '../../shared/documentation-items/documentation-items';
912
import {NavigationFocus} from '../../shared/navigation-focus/navigation-focus';
1013

1114
import {ComponentPageTitle} from '../page-title/page-title';
@@ -15,32 +18,31 @@ import {ComponentPageTitle} from '../page-title/page-title';
1518
templateUrl: './component-category-list.html',
1619
styleUrls: ['./component-category-list.scss'],
1720
standalone: true,
18-
imports: [NavigationFocus, RouterLink, AsyncPipe, MatRipple],
21+
imports: [NavigationFocus, RouterLink, MatRipple],
1922
})
2023
export class ComponentCategoryList implements OnInit, OnDestroy {
21-
params: Observable<Params> | undefined;
24+
items: DocItem[] = [];
25+
section = '';
2226
routeParamSubscription: Subscription = new Subscription();
2327
_categoryListSummary: string | undefined;
2428

2529
constructor(
26-
public docItems: DocumentationItems,
27-
public _componentPageTitle: ComponentPageTitle,
30+
readonly _docItems: DocumentationItems,
31+
private _componentPageTitle: ComponentPageTitle,
2832
private _route: ActivatedRoute,
2933
) {}
3034

3135
ngOnInit() {
32-
// Combine params from all of the path into a single object.
33-
this.params = combineLatest(
36+
this.routeParamSubscription = combineLatest(
3437
this._route.pathFromRoot.map(route => route.params),
3538
Object.assign,
36-
);
37-
38-
// title on topbar navigation
39-
this.routeParamSubscription = this.params.subscribe(params => {
39+
).subscribe(async params => {
4040
const sectionName = params['section'];
4141
const section = SECTIONS[sectionName];
4242
this._componentPageTitle.title = section.name;
4343
this._categoryListSummary = section.summary;
44+
this.section = sectionName;
45+
this.items = await this._docItems.getItems(sectionName);
4446
});
4547
}
4648

Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
<div class="docs-component-viewer-nav">
2-
@if ((params() | async)?.section; as section) {
2+
@let items = (this.items | async) || [];
3+
4+
@if (items.length > 0) {
35
<div class="docs-component-viewer-nav-content">
4-
<mat-nav-list>
5-
@for (component of docItems.getItems(section); track component) {
6-
<a mat-list-item #link="routerLinkActive"
7-
[routerLink]="'/' + section+ '/' + component.id"
8-
[activated]="link.isActive"
9-
routerLinkActive="docs-component-viewer-sidenav-item-selected"
10-
[attr.aria-current]="currentItemId === component.id ? 'page': 'false'">
11-
{{component.name}}
12-
</a>
13-
}
14-
</mat-nav-list>
6+
<mat-nav-list>
7+
@for (component of items; track component) {
8+
<a mat-list-item #link="routerLinkActive"
9+
[routerLink]="'/' + params()?.section + '/' + component.id"
10+
[activated]="link.isActive"
11+
routerLinkActive="docs-component-viewer-sidenav-item-selected"
12+
[attr.aria-current]="link.isActive ? 'page': 'false'">
13+
{{component.name}}
14+
</a>
15+
}
16+
</mat-nav-list>
1517
</div>
1618
}
1719
</div>

‎material.angular.io/src/app/pages/component-sidenav/component-sidenav.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[mode]="(isScreenSmall | async) ? 'over' : 'side'"
77
[fixedInViewport]="(isScreenSmall | async)"
88
[fixedTopGap]="(isExtraScreenSmall | async) ? 92 : 56">
9-
<app-component-nav [params]="params"></app-component-nav>
9+
<app-component-nav [params]="params | async"></app-component-nav>
1010
</mat-sidenav>
1111
}
1212
<div class="docs-component-sidenav-content">
@@ -15,7 +15,7 @@
1515
<main class="docs-component-sidenav-body-content">
1616
<!-- If on large screen, menu resides to left of content -->
1717
@if ((isScreenSmall | async) === false) {
18-
<app-component-nav [params]="params"/>
18+
<app-component-nav [params]="params | async"/>
1919
}
2020
<router-outlet></router-outlet>
2121
</main>

‎material.angular.io/src/app/pages/component-sidenav/component-sidenav.spec.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ describe('ComponentSidenav', () => {
3434
});
3535
});
3636

37-
it('should show a link for each item in doc items categories', () => {
38-
const totalItems = component.docItems.getItems('categories').length;
37+
it('should show a link for each item in doc items categories', async () => {
38+
const items = await component.docItems.getItems('categories');
39+
const totalItems = items.length;
3940
const totalLinks = fixture.nativeElement.querySelectorAll(
4041
'.docs-component-viewer-sidenav li a',
4142
).length;

‎material.angular.io/src/app/pages/component-sidenav/component-sidenav.ts

+16-13
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {
22
Component,
33
NgModule,
4-
NgZone,
54
OnDestroy,
65
OnInit,
76
ViewEncapsulation,
87
forwardRef,
8+
inject,
99
input,
1010
viewChild,
1111
} from '@angular/core';
12+
import {toObservable} from '@angular/core/rxjs-interop';
1213
import {CdkAccordionModule} from '@angular/cdk/accordion';
1314
import {BreakpointObserver} from '@angular/cdk/layout';
1415
import {AsyncPipe} from '@angular/common';
@@ -26,7 +27,7 @@ import {
2627
RouterLink,
2728
} from '@angular/router';
2829
import {combineLatest, Observable, Subscription} from 'rxjs';
29-
import {map} from 'rxjs/operators';
30+
import {map, switchMap} from 'rxjs/operators';
3031

3132
import {DocViewerModule} from '../../shared/doc-viewer/doc-viewer-module';
3233
import {DocumentationItems} from '../../shared/documentation-items/documentation-items';
@@ -75,16 +76,15 @@ const SMALL_WIDTH_BREAKPOINT = 959;
7576
})
7677
export class ComponentSidenav implements OnInit, OnDestroy {
7778
readonly sidenav = viewChild(MatSidenav);
78-
params: Observable<Params> | undefined;
79+
params: Observable<Params>;
7980
isExtraScreenSmall: Observable<boolean>;
8081
isScreenSmall: Observable<boolean>;
81-
private subscriptions = new Subscription();
82+
private _subscriptions = new Subscription();
8283

8384
constructor(
8485
public docItems: DocumentationItems,
8586
private _route: ActivatedRoute,
8687
private _navigationFocusService: NavigationFocusService,
87-
zone: NgZone,
8888
breakpoints: BreakpointObserver,
8989
) {
9090
this.isExtraScreenSmall = breakpoints
@@ -93,16 +93,15 @@ export class ComponentSidenav implements OnInit, OnDestroy {
9393
this.isScreenSmall = breakpoints
9494
.observe(`(max-width: ${SMALL_WIDTH_BREAKPOINT}px)`)
9595
.pipe(map(breakpoint => breakpoint.matches));
96-
}
9796

98-
ngOnInit() {
99-
// Combine params from all of the path into a single object.
10097
this.params = combineLatest(
10198
this._route.pathFromRoot.map(route => route.params),
10299
Object.assign,
103100
);
101+
}
104102

105-
this.subscriptions.add(
103+
ngOnInit() {
104+
this._subscriptions.add(
106105
this._navigationFocusService.navigationEndEvents
107106
.pipe(map(() => this.isScreenSmall))
108107
.subscribe(shouldCloseSideNav => {
@@ -115,7 +114,7 @@ export class ComponentSidenav implements OnInit, OnDestroy {
115114
}
116115

117116
ngOnDestroy() {
118-
this.subscriptions.unsubscribe();
117+
this._subscriptions.unsubscribe();
119118
}
120119

121120
toggleSidenav(): void {
@@ -130,10 +129,14 @@ export class ComponentSidenav implements OnInit, OnDestroy {
130129
imports: [MatListModule, RouterLinkActive, RouterLink, AsyncPipe],
131130
})
132131
export class ComponentNav {
133-
readonly params = input<Observable<Params>>();
134-
currentItemId: string | undefined;
132+
private _docItems = inject(DocumentationItems);
133+
readonly params = input<Params | null>();
135134

136-
constructor(public docItems: DocumentationItems) {}
135+
items = toObservable(this.params).pipe(
136+
switchMap(params =>
137+
params?.section ? this._docItems.getItems(params.section) : Promise.resolve(null),
138+
),
139+
);
137140
}
138141

139142
const routes: Routes = [

‎material.angular.io/src/app/pages/component-viewer/component-viewer.spec.ts

-54
This file was deleted.

‎material.angular.io/src/app/pages/component-viewer/component-viewer.ts

+6-9
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,14 @@ import {
1414
import {MatTabsModule} from '@angular/material/tabs';
1515
import {
1616
ActivatedRoute,
17-
Params,
1817
Router,
1918
RouterModule,
2019
RouterLinkActive,
2120
RouterLink,
2221
RouterOutlet,
2322
} from '@angular/router';
2423
import {combineLatest, Observable, ReplaySubject, Subject} from 'rxjs';
25-
import {map, skip, takeUntil} from 'rxjs/operators';
24+
import {map, skip, switchMap, takeUntil} from 'rxjs/operators';
2625
import {DocViewerModule} from '../../shared/doc-viewer/doc-viewer-module';
2726
import {DocItem, DocumentationItems} from '../../shared/documentation-items/documentation-items';
2827
import {TableOfContents} from '../../shared/table-of-contents/table-of-contents';
@@ -59,15 +58,13 @@ export class ComponentViewer implements OnDestroy {
5958
// parent route for the section (material/cdk).
6059
combineLatest(routeAndParentParams)
6160
.pipe(
62-
map((params: Params[]) => {
61+
switchMap(async params => {
6362
const id = params[0]['id'];
6463
const section = params[1]['section'];
65-
66-
return {
67-
doc: docItems.getItemById(id, section),
68-
section: section,
69-
};
70-
}, takeUntil(this._destroyed)),
64+
const doc = await docItems.getItemById(id, section);
65+
return {doc, section};
66+
}),
67+
takeUntil(this._destroyed),
7168
)
7269
.subscribe(({doc, section}) => {
7370
if (!doc) {

‎material.angular.io/src/app/shared/documentation-items/documentation-items.spec.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,31 @@ describe('DocViewer', () => {
1515
docsItems = di;
1616
}));
1717

18-
it('should get a list of all doc items', () => {
19-
expect(docsItems.getItems(COMPONENTS)).toBeDefined();
20-
expect(docsItems.getItems(COMPONENTS).length).toBeGreaterThan(0);
21-
for (const item of docsItems.getItems(COMPONENTS)) {
18+
it('should get a list of all doc items', async () => {
19+
const items = await docsItems.getItems(COMPONENTS);
20+
21+
expect(items).toBeDefined();
22+
expect(items.length).toBeGreaterThan(0);
23+
for (const item of items) {
2224
expect(item.id).toBeDefined();
2325
expect(item.name).toBeDefined();
2426
}
2527
});
2628

27-
it('should get a doc item by id', () => {
28-
expect(docsItems.getItemById('button', 'material')).toBeDefined();
29+
it('should get a doc item by id', async () => {
30+
expect(await docsItems.getItemById('button', 'material')).toBeDefined();
2931
});
3032

31-
it('should be sorted alphabetically (components)', () => {
32-
const components = docsItems.getItems(COMPONENTS).map(c => c.name);
33+
it('should be sorted alphabetically (components)', async () => {
34+
const items = await docsItems.getItems(COMPONENTS);
35+
const components = items.map(c => c.name);
3336
const sortedComponents = components.concat().sort();
3437
expect(components).toEqual(sortedComponents);
3538
});
3639

37-
it('should be sorted alphabetically (cdk)', () => {
38-
const cdk = docsItems.getItems(CDK).map(c => c.name);
40+
it('should be sorted alphabetically (cdk)', async () => {
41+
const items = await docsItems.getItems(CDK);
42+
const cdk = items.map(c => c.name);
3943
const sortedCdk = cdk.concat().sort();
4044
expect(cdk).toEqual(sortedCdk);
4145
});

‎material.angular.io/src/app/shared/documentation-items/documentation-items.ts

+40-21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Injectable} from '@angular/core';
2-
import {EXAMPLE_COMPONENTS} from '@angular/components-examples';
2+
import type {LiveExample} from '@angular/components-examples';
33

44
export interface AdditionalApiDoc {
55
name: string;
@@ -39,7 +39,6 @@ export interface DocSection {
3939
summary: string;
4040
}
4141

42-
const exampleNames = Object.keys(EXAMPLE_COMPONENTS);
4342
const CDK = 'cdk';
4443
const COMPONENTS = 'components';
4544
export const SECTIONS: {[key: string]: DocSection} = {
@@ -582,38 +581,58 @@ const DOCS: {[key: string]: DocItem[]} = {
582581
// docs more granularly than directory-level (within a11y) (same for viewport).
583582
};
584583

585-
const ALL_COMPONENTS = processDocs('material', DOCS[COMPONENTS]);
586-
const ALL_CDK = processDocs('cdk', DOCS[CDK]);
587-
const ALL_DOCS = [...ALL_COMPONENTS, ...ALL_CDK];
584+
interface DocsData {
585+
cdk: DocItem[];
586+
components: DocItem[];
587+
all: DocItem[];
588+
examples: Record<string, LiveExample>;
589+
}
588590

589591
@Injectable({providedIn: 'root'})
590592
export class DocumentationItems {
591-
getItems(section: string): DocItem[] {
593+
private _cachedData: DocsData | null = null;
594+
595+
async getItems(section: string): Promise<DocItem[]> {
596+
const data = await this.getData();
592597
if (section === COMPONENTS) {
593-
return ALL_COMPONENTS;
598+
return data.components;
594599
}
595600
if (section === CDK) {
596-
return ALL_CDK;
601+
return data.cdk;
597602
}
598603
return [];
599604
}
600605

601-
getItemById(id: string, section: string): DocItem | undefined {
606+
async getItemById(id: string, section: string): Promise<DocItem | undefined> {
607+
const docs = (await this.getData()).all;
602608
const sectionLookup = section === 'cdk' ? 'cdk' : 'material';
603-
return ALL_DOCS.find(doc => doc.id === id && doc.packageName === sectionLookup);
609+
return docs.find(doc => doc.id === id && doc.packageName === sectionLookup);
604610
}
605-
}
606611

607-
function processDocs(packageName: string, docs: DocItem[]): DocItem[] {
608-
for (const doc of docs) {
609-
doc.packageName = packageName;
610-
doc.hasStyling ??= packageName === 'material';
611-
doc.examples = exampleNames.filter(
612-
key =>
613-
key.match(RegExp(`^${doc.exampleSpecs.prefix}`)) &&
614-
!doc.exampleSpecs.exclude?.some(excludeName => key.indexOf(excludeName) === 0),
615-
);
612+
async getData(): Promise<DocsData> {
613+
if (!this._cachedData) {
614+
const examples = (await import('@angular/components-examples')).EXAMPLE_COMPONENTS;
615+
const exampleNames = Object.keys(examples);
616+
const components = this._processDocs('material', exampleNames, DOCS[COMPONENTS]);
617+
const cdk = this._processDocs('cdk', exampleNames, DOCS[CDK]);
618+
619+
this._cachedData = {components, cdk, all: [...components, ...cdk], examples};
620+
}
621+
622+
return this._cachedData;
616623
}
617624

618-
return docs.sort((a, b) => a.name.localeCompare(b.name, 'en'));
625+
private _processDocs(packageName: string, exampleNames: string[], docs: DocItem[]): DocItem[] {
626+
for (const doc of docs) {
627+
doc.packageName = packageName;
628+
doc.hasStyling ??= packageName === 'material';
629+
doc.examples = exampleNames.filter(
630+
key =>
631+
key.match(RegExp(`^${doc.exampleSpecs.prefix}`)) &&
632+
!doc.exampleSpecs.exclude?.some(excludeName => key.indexOf(excludeName) === 0),
633+
);
634+
}
635+
636+
return docs.sort((a, b) => a.name.localeCompare(b.name, 'en'));
637+
}
619638
}
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import {HttpTestingController} from '@angular/common/http/testing';
21
import {NgModule} from '@angular/core';
3-
import {waitForAsync, ComponentFixture, inject, TestBed} from '@angular/core/testing';
2+
import {ComponentFixture, TestBed} from '@angular/core/testing';
43
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
54
import {MatAutocompleteModule} from '@angular/material/autocomplete';
65
import {MatInputModule} from '@angular/material/input';
76
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
8-
import {MatSnackBar} from '@angular/material/snack-bar';
9-
import {Clipboard} from '@angular/cdk/clipboard';
107

118
import {EXAMPLE_COMPONENTS} from '@angular/components-examples';
12-
import {By} from '@angular/platform-browser';
139
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1410
import {DocsAppTestingModule} from '../../testing/testing-module';
1511
import {DocViewerModule} from '../doc-viewer/doc-viewer-module';
@@ -25,18 +21,24 @@ const exampleBasePath = `/docs-content/examples-highlighted/material/autocomplet
2521
describe('ExampleViewer', () => {
2622
let fixture: ComponentFixture<ExampleViewer>;
2723
let component: ExampleViewer;
28-
let http: HttpTestingController;
2924
let loader: HarnessLoader;
3025

31-
beforeEach(waitForAsync(() => {
26+
function waitForTabsLoad() {
27+
return new Promise<void>(resolve => {
28+
const interval = setInterval(() => {
29+
if (component.exampleTabs) {
30+
clearInterval(interval);
31+
resolve();
32+
}
33+
}, 100);
34+
});
35+
}
36+
37+
beforeEach(() => {
3238
TestBed.configureTestingModule({
3339
imports: [DocViewerModule, DocsAppTestingModule, ReactiveFormsModule, TestExampleModule],
3440
}).compileComponents();
35-
}));
36-
37-
beforeEach(inject([HttpTestingController], (h: HttpTestingController) => {
38-
http = h;
39-
}));
41+
});
4042

4143
beforeEach(() => {
4244
fixture = TestBed.createComponent(ExampleViewer);
@@ -45,7 +47,7 @@ describe('ExampleViewer', () => {
4547
loader = TestbedHarnessEnvironment.loader(fixture);
4648
});
4749

48-
it('should toggle between the 3 views', waitForAsync(() => {
50+
it('should toggle between the 3 views', () => {
4951
// need to specify a file because toggling from snippet to full changes the tabs to match
5052
component.file = 'file.html';
5153
component.view = 'snippet';
@@ -54,80 +56,89 @@ describe('ExampleViewer', () => {
5456
expect(component.view).toBe('full');
5557
component.toggleSourceView();
5658
expect(component.view).toBe('demo');
57-
}));
59+
});
5860

59-
it('should expand to HTML tab', waitForAsync(async () => {
61+
it('should expand to HTML tab', async () => {
6062
component.example = exampleKey;
6163
component.file = 'file.html';
6264
component.view = 'snippet';
65+
await waitForTabsLoad();
6366
component.toggleCompactView();
6467

6568
const tabGroup = await loader.getHarness(MatTabGroupHarness);
6669
const tab = await tabGroup.getSelectedTab();
6770
expect(await tab.getLabel()).toBe('HTML');
68-
}));
71+
});
6972

70-
it('should expand to TS tab', waitForAsync(async () => {
73+
it('should expand to TS tab', async () => {
7174
component.example = exampleKey;
7275
component.file = EXAMPLE_COMPONENTS[exampleKey].primaryFile;
7376
component.view = 'snippet';
77+
await waitForTabsLoad();
7478
component.toggleCompactView();
7579

7680
const tabGroup = await loader.getHarness(MatTabGroupHarness);
7781
const tab = await tabGroup.getSelectedTab();
7882
expect(await tab.getLabel()).toBe('TS');
79-
}));
83+
});
8084

81-
it('should expand to CSS tab', waitForAsync(async () => {
85+
it('should expand to CSS tab', async () => {
8286
component.example = exampleKey;
8387
component.file = 'file.css';
8488
component.view = 'snippet';
89+
await waitForTabsLoad();
8590
component.toggleCompactView();
8691

8792
const tabGroup = await loader.getHarness(MatTabGroupHarness);
8893
const tab = await tabGroup.getSelectedTab();
8994
expect(await tab.getLabel()).toBe('CSS');
90-
}));
95+
});
9196

92-
it('should generate correct url with region', waitForAsync(() => {
97+
it('should generate correct url with region', async () => {
9398
component.example = exampleKey;
9499
component.region = 'region';
100+
await waitForTabsLoad();
95101
const url = component.generateUrl('a.b.html');
96102
expect(url).toBe(exampleBasePath + '/a.b_region-html.html');
97-
}));
103+
});
98104

99-
it('should generate correct url without region', waitForAsync(() => {
105+
it('should generate correct url without region', async () => {
100106
component.example = exampleKey;
101107
component.region = undefined;
108+
await waitForTabsLoad();
102109
const url = component.generateUrl('a.b.ts');
103110
expect(url).toBe(exampleBasePath + '/a.b-ts.html');
104-
}));
111+
});
105112

106-
it('should print an error message about incorrect file type', waitForAsync(() => {
113+
it('should print an error message about incorrect file type', async () => {
107114
spyOn(console, 'error');
108115
component.example = exampleKey;
109116
component.file = 'file.bad';
117+
await waitForTabsLoad();
110118
component.selectCorrectTab();
111119

112120
expect(console.error).toHaveBeenCalledWith(`Could not find tab for file extension: "bad".`);
113-
}));
121+
});
114122

115-
it('should set and return example properly', waitForAsync(() => {
123+
it('should set and return example properly', async () => {
116124
component.example = exampleKey;
125+
await waitForTabsLoad();
117126
const data = component.exampleData;
118127
expect(data).toEqual(EXAMPLE_COMPONENTS[exampleKey]);
119-
}));
128+
});
120129

121-
it('should print an error message about missing example', waitForAsync(() => {
130+
it('should print an error message about missing example', async () => {
122131
spyOn(console, 'error');
123132
component.example = 'foobar';
133+
await waitForTabsLoad();
124134
expect(console.error).toHaveBeenCalled();
125135
expect(console.error).toHaveBeenCalledWith('Could not find example: foobar');
126-
}));
136+
});
127137

128-
it('should return docs-content path for example based on extension', waitForAsync(() => {
138+
it('should return docs-content path for example based on extension', async () => {
129139
// set example
130140
component.example = exampleKey;
141+
await waitForTabsLoad();
131142

132143
// get example file path for each extension
133144
const extensions = ['ts', 'css', 'html'];
@@ -138,16 +149,17 @@ describe('ExampleViewer', () => {
138149

139150
expect(actual).toEqual(expected);
140151
});
141-
}));
152+
});
142153

143154
describe('view-source tab group', () => {
144-
it('should only render HTML, TS and CSS files if no additional files are specified', () => {
155+
it('should only render HTML, TS and CSS files if no additional files are specified', async () => {
145156
component.example = exampleKey;
157+
await waitForTabsLoad();
146158

147159
expect(component._getExampleTabNames()).toEqual(['HTML', 'TS', 'CSS']);
148160
});
149161

150-
it('should be able to render additional files', () => {
162+
it('should be able to render additional files', async () => {
151163
EXAMPLE_COMPONENTS['additional-files'] = {
152164
...EXAMPLE_COMPONENTS[exampleKey],
153165
files: [
@@ -160,6 +172,7 @@ describe('ExampleViewer', () => {
160172
};
161173

162174
component.example = 'additional-files';
175+
await waitForTabsLoad();
163176

164177
expect(component._getExampleTabNames()).toEqual([
165178
'HTML',
@@ -170,65 +183,18 @@ describe('ExampleViewer', () => {
170183
]);
171184
});
172185

173-
it('should be possible for example to not have CSS or HTML files', () => {
186+
it('should be possible for example to not have CSS or HTML files', async () => {
174187
EXAMPLE_COMPONENTS['additional-files'] = {
175188
...EXAMPLE_COMPONENTS[exampleKey],
176189
files: ['additional-files-example.ts'],
177190
};
178191

179192
component.example = 'additional-files';
193+
await waitForTabsLoad();
180194

181195
expect(component._getExampleTabNames()).toEqual(['TS']);
182196
});
183197
});
184-
185-
describe('copy button', () => {
186-
let button: HTMLElement;
187-
188-
beforeEach(() => {
189-
// Open source view
190-
component.example = exampleKey;
191-
component.view = 'full';
192-
fixture.detectChanges();
193-
194-
for (const url of Object.keys(FAKE_DOCS)) {
195-
http.expectOne(url).flush(FAKE_DOCS[url]);
196-
}
197-
198-
// Select button element
199-
const btnDe = fixture.debugElement.query(By.css('.docs-example-source-copy'));
200-
button = btnDe ? btnDe.nativeElement : null;
201-
});
202-
203-
it('should call clipboard service when clicked', () => {
204-
const clipboardService = TestBed.inject(Clipboard);
205-
const spy = spyOn(clipboardService, 'copy');
206-
expect(spy.calls.count()).toBe(0, 'before click');
207-
button.click();
208-
expect(spy.calls.count()).toBe(1, 'after click');
209-
expect(spy.calls.argsFor(0)[0]).toBe('my docs page', 'click content');
210-
});
211-
212-
it('should display a message when copy succeeds', () => {
213-
const snackBar: MatSnackBar = TestBed.inject(MatSnackBar);
214-
const clipboardService = TestBed.inject(Clipboard);
215-
spyOn(snackBar, 'open');
216-
spyOn(clipboardService, 'copy').and.returnValue(true);
217-
button.click();
218-
expect(snackBar.open).toHaveBeenCalledWith('Code copied', '', {duration: 2500});
219-
});
220-
221-
it('should display an error when copy fails', () => {
222-
const snackBar: MatSnackBar = TestBed.inject(MatSnackBar);
223-
const clipboardService = TestBed.inject(Clipboard);
224-
spyOn(snackBar, 'open');
225-
spyOn(clipboardService, 'copy').and.returnValue(false);
226-
button.click();
227-
expect(snackBar.open).toHaveBeenCalledWith('Copy failed. Please try again!', '', {
228-
duration: 2500,
229-
});
230-
});
231-
});
232198
});
233199

234200
// Create a version of ExampleModule for testing with only one component so that we don't have
@@ -245,10 +211,3 @@ describe('ExampleViewer', () => {
245211
],
246212
})
247213
class TestExampleModule {}
248-
249-
const FAKE_DOCS: {[key: string]: string} = {
250-
[`${exampleBasePath}/autocomplete-overview-example-html.html`]: '<div>my docs page</div>',
251-
[`${exampleBasePath}/autocomplete-overview-example-ts.html`]: '<span>const a = 1;</span>',
252-
[`${exampleBasePath}/autocomplete-overview-example-css.html`]:
253-
'<pre>.class { color: black; }</pre>',
254-
};

‎material.angular.io/src/app/shared/example-viewer/example-viewer.ts

+38-32
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import {Component, ElementRef, HostBinding, Input, OnInit, Type, viewChildren} from '@angular/core';
1+
import {Component, ElementRef, inject, Input, OnInit, Type, viewChildren} from '@angular/core';
22
import {MatSnackBar} from '@angular/material/snack-bar';
33
import {Clipboard} from '@angular/cdk/clipboard';
44

5-
import {EXAMPLE_COMPONENTS, LiveExample, loadExample} from '@angular/components-examples';
5+
import {type LiveExample, loadExample} from '@angular/components-examples';
66
import {CodeSnippet} from './code-snippet';
77
import {normalizePath} from '../normalize-path';
88
import {MatTabsModule} from '@angular/material/tabs';
@@ -11,6 +11,7 @@ import {MatIconModule} from '@angular/material/icon';
1111
import {MatTooltipModule} from '@angular/material/tooltip';
1212
import {MatButtonModule} from '@angular/material/button';
1313
import {NgComponentOutlet} from '@angular/common';
14+
import {DocumentationItems} from '../documentation-items/documentation-items';
1415

1516
export type Views = 'snippet' | 'full' | 'demo';
1617

@@ -34,8 +35,15 @@ const preferredExampleFileOrder = ['HTML', 'TS', 'CSS'];
3435
CodeSnippet,
3536
NgComponentOutlet,
3637
],
38+
host: {
39+
'[attr.id]': 'example',
40+
},
3741
})
3842
export class ExampleViewer implements OnInit {
43+
private readonly _snackbar = inject(MatSnackBar);
44+
private readonly _clipboard = inject(Clipboard);
45+
private readonly _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
46+
private readonly _docsItems = inject(DocumentationItems);
3947
readonly snippet = viewChildren(CodeSnippet);
4048

4149
/** The tab to jump to when expanding from snippet view. */
@@ -60,21 +68,14 @@ export class ExampleViewer implements OnInit {
6068
@Input() showCompactToggle = false;
6169

6270
/** String key of the currently displayed example. */
63-
@HostBinding('attr.id')
6471
@Input()
6572
get example() {
6673
return this._example;
6774
}
6875
set example(exampleName: string | undefined) {
69-
if (exampleName && exampleName !== this._example && EXAMPLE_COMPONENTS[exampleName]) {
76+
if (exampleName && exampleName !== this._example) {
7077
this._example = exampleName;
71-
this.exampleData = EXAMPLE_COMPONENTS[exampleName];
72-
this._generateExampleTabs();
73-
this._loadExampleComponent().catch(error =>
74-
console.error(`Could not load example '${exampleName}': ${error}`),
75-
);
76-
} else {
77-
console.error(`Could not find example: ${exampleName}`);
78+
this._exampleChanged(exampleName);
7879
}
7980
}
8081
private _example: string | undefined;
@@ -85,12 +86,6 @@ export class ExampleViewer implements OnInit {
8586
/** Name of file to display in compact view. */
8687
@Input() file?: string;
8788

88-
constructor(
89-
private readonly snackbar: MatSnackBar,
90-
private readonly clipboard: Clipboard,
91-
private readonly elementRef: ElementRef<HTMLElement>,
92-
) {}
93-
9489
ngOnInit() {
9590
if (this.file) {
9691
this.fileUrl = this.generateUrl(this.file);
@@ -132,10 +127,10 @@ export class ExampleViewer implements OnInit {
132127

133128
copySource(snippets: readonly CodeSnippet[], selectedIndex: number = 0) {
134129
const text = snippets[selectedIndex].viewer().textContent || '';
135-
if (this.clipboard.copy(text)) {
136-
this.snackbar.open('Code copied', '', {duration: 2500});
130+
if (this._clipboard.copy(text)) {
131+
this._snackbar.open('Code copied', '', {duration: 2500});
137132
} else {
138-
this.snackbar.open('Copy failed. Please try again!', '', {duration: 2500});
133+
this._snackbar.open('Copy failed. Please try again!', '', {duration: 2500});
139134
}
140135
}
141136

@@ -179,28 +174,39 @@ export class ExampleViewer implements OnInit {
179174
_copyLink() {
180175
// Reconstruct the URL using `origin + pathname` so we drop any pre-existing hash.
181176
const fullUrl = location.origin + location.pathname + '#' + this._example;
177+
const copySuccessful = this._clipboard.copy(fullUrl);
182178

183-
if (this.clipboard.copy(fullUrl)) {
184-
this.snackbar.open('Link copied', '', {duration: 2500});
185-
} else {
186-
this.snackbar.open('Link copy failed. Please try again!', '', {duration: 2500});
187-
}
179+
this._snackbar.open(
180+
copySuccessful ? 'Link copied' : 'Link copy failed. Please try again!',
181+
'',
182+
{duration: 2500},
183+
);
188184
}
189185

190-
/** Loads the component and module factory for the currently selected example. */
191-
private async _loadExampleComponent() {
192-
if (this._example != null) {
193-
const {componentName} = EXAMPLE_COMPONENTS[this._example];
186+
private async _exampleChanged(name: string) {
187+
const examples = (await this._docsItems.getData()).examples;
188+
this.exampleData = examples[name];
189+
190+
if (!this.exampleData) {
191+
console.error(`Could not find example: ${name}`);
192+
return;
193+
}
194+
195+
try {
196+
this._generateExampleTabs();
197+
194198
// Lazily loads the example package that contains the requested example.
195-
const moduleExports = await loadExample(this._example);
196-
this._exampleComponentType = moduleExports[componentName];
199+
const moduleExports = await loadExample(name);
200+
this._exampleComponentType = moduleExports[examples[name].componentName];
197201

198202
// Since the data is loaded asynchronously, we can't count on the native behavior
199203
// that scrolls the element into view automatically. We do it ourselves while giving
200204
// the page some time to render.
201205
if (typeof location !== 'undefined' && location.hash.slice(1) === this._example) {
202-
setTimeout(() => this.elementRef.nativeElement.scrollIntoView(), 300);
206+
setTimeout(() => this._elementRef.nativeElement.scrollIntoView(), 300);
203207
}
208+
} catch (e) {
209+
console.error(`Could not load example '${name}': ${e}`);
204210
}
205211
}
206212

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

+3-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {HttpClient} from '@angular/common/http';
22
import {Injectable, NgZone} from '@angular/core';
3-
import {EXAMPLE_COMPONENTS, ExampleData} from '@angular/components-examples';
3+
import type {ExampleData} from '@angular/components-examples';
44
import {Observable, firstValueFrom} from 'rxjs';
55
import {shareReplay} from 'rxjs/operators';
66

@@ -123,9 +123,10 @@ export class StackBlitzWriter {
123123
exampleId: string,
124124
isTest: boolean,
125125
): Promise<FileDictionary> {
126+
const examples = await import('@angular/components-examples');
126127
const result: FileDictionary = {};
127128
const tasks: Promise<unknown>[] = [];
128-
const liveExample = EXAMPLE_COMPONENTS[exampleId];
129+
const liveExample = examples.EXAMPLE_COMPONENTS[exampleId];
129130
const exampleBaseContentPath = `${DOCS_CONTENT_PATH}/${liveExample.importPath}/${exampleId}/`;
130131

131132
for (const relativeFilePath of TEMPLATE_FILES) {
@@ -174,12 +175,6 @@ export class StackBlitzWriter {
174175
return firstValueFrom(stream);
175176
}
176177

177-
/**
178-
* The StackBlitz template assets contain placeholder names for the examples:
179-
* "<material-docs-example>" and "MaterialDocsExample".
180-
* This will replace those placeholders with the names from the example metadata,
181-
* e.g. "<basic-button-example>" and "BasicButtonExample"
182-
*/
183178
private _replaceExamplePlaceholders(
184179
data: ExampleData,
185180
fileName: string,

0 commit comments

Comments
 (0)
Please sign in to comment.