From 1f4c861b7d64221cbc28b2b25ec02efe33d8b3c7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 24 Jun 2025 13:26:41 +0200 Subject: [PATCH] docs: snippet-based examples not showing after clicking away and coming back Fixes that examples based on snippets (e.g. first example for autocomplete) weren't showing up if the user lands on the page, clicks to another tab and then comes back. The problem was that the timing is different on the second run, because we hit the cached rather than fetching the content. These changes resolve it by switching the component's internal state to signals. --- .../app/shared/doc-viewer/doc-viewer.spec.ts | 29 ++-- docs/src/app/shared/doc-viewer/doc-viewer.ts | 24 ++- .../shared/example-viewer/example-viewer.html | 25 +-- .../shared/example-viewer/example-viewer.ts | 158 ++++++++++-------- 4 files changed, 134 insertions(+), 102 deletions(-) diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts index d8ccc111b343..9c500951c83b 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.spec.ts @@ -95,11 +95,12 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBe('some-example.html'); - expect(exampleViewer.componentInstance.showCompactToggle).toBeTrue(); - expect(exampleViewer.componentInstance.region).toBe('some-region'); - expect(exampleViewer.componentInstance.view).toBe('snippet'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBe('some-example.html'); + expect(exampleViewer.showCompactToggle()).toBeTrue(); + expect(exampleViewer.region()).toBe('some-region'); + expect(exampleViewer.view()).toBe('snippet'); }); it('should instantiate example viewer in demo view', () => { @@ -111,10 +112,11 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBeUndefined(); - expect(exampleViewer.componentInstance.showCompactToggle).toBeFalse(); - expect(exampleViewer.componentInstance.view).toBe('demo'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBeUndefined(); + expect(exampleViewer.showCompactToggle()).toBeFalse(); + expect(exampleViewer.view()).toBe('demo'); }); it('should instantiate example viewer in snippet view with whole snippet', () => { @@ -126,10 +128,11 @@ describe('DocViewer', () => { http.expectOne(testUrl).flush(FAKE_DOCS[testUrl]); - const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)); - expect(exampleViewer.componentInstance.file).toBe('whole-snippet-example.ts'); - expect(exampleViewer.componentInstance.showCompactToggle).toBeTrue(); - expect(exampleViewer.componentInstance.view).toBe('snippet'); + const exampleViewer = fixture.debugElement.query(By.directive(ExampleViewer)) + .componentInstance as ExampleViewer; + expect(exampleViewer.file()).toBe('whole-snippet-example.ts'); + expect(exampleViewer.showCompactToggle()).toBeTrue(); + expect(exampleViewer.view()).toBe('snippet'); }); it('should show error message when doc not found', () => { diff --git a/docs/src/app/shared/doc-viewer/doc-viewer.ts b/docs/src/app/shared/doc-viewer/doc-viewer.ts index e0f2cb6b80d5..89b3b73cc81b 100644 --- a/docs/src/app/shared/doc-viewer/doc-viewer.ts +++ b/docs/src/app/shared/doc-viewer/doc-viewer.ts @@ -30,6 +30,7 @@ import { ViewContainerRef, input, inject, + Type, } from '@angular/core'; import {Observable, Subscription} from 'rxjs'; import {shareReplay, take, tap} from 'rxjs/operators'; @@ -113,17 +114,17 @@ export class DocViewer implements OnDestroy { if (file) { // if the html div has field `file` then it should be in compact view to show the code // snippet - exampleViewerComponent.view = 'snippet'; - exampleViewerComponent.showCompactToggle = true; - exampleViewerComponent.file = file; + exampleViewerComponent.view.set('snippet'); + exampleViewerComponent.showCompactToggle.set(true); + exampleViewerComponent.file.set(file); if (region) { // `region` should only exist when `file` exists but not vice versa // It is valid for embedded example snippets to show the whole file (esp short files) - exampleViewerComponent.region = region; + exampleViewerComponent.region.set(region); } } else { // otherwise it is an embedded demo - exampleViewerComponent.view = 'demo'; + exampleViewerComponent.view.set('demo'); } } @@ -175,7 +176,7 @@ export class DocViewer implements OnDestroy { } /** Instantiate a ExampleViewer for each example. */ - private _loadComponents(componentName: string, componentClass: any) { + private _loadComponents(componentName: string, componentClass: Type) { const exampleElements = this._elementRef.nativeElement.querySelectorAll(`[${componentName}]`); [...exampleElements].forEach((element: Element) => { @@ -185,9 +186,14 @@ export class DocViewer implements OnDestroy { const portalHost = new DomPortalOutlet(element, this._appRef, this._injector); const examplePortal = new ComponentPortal(componentClass, this._viewContainerRef); const exampleViewer = portalHost.attach(examplePortal); - const exampleViewerComponent = exampleViewer.instance as ExampleViewer; - if (example !== null) { - DocViewer._initExampleViewer(exampleViewerComponent, example, file, region); + const exampleViewerComponent = exampleViewer.instance; + if (example !== null && componentClass === ExampleViewer) { + DocViewer._initExampleViewer( + exampleViewerComponent as ExampleViewer, + example, + file, + region, + ); } this._portalHosts.push(portalHost); }); diff --git a/docs/src/app/shared/example-viewer/example-viewer.html b/docs/src/app/shared/example-viewer/example-viewer.html index 3216a817e9dd..96b7f0877c96 100644 --- a/docs/src/app/shared/example-viewer/example-viewer.html +++ b/docs/src/app/shared/example-viewer/example-viewer.html @@ -1,5 +1,8 @@ +@let exampleData = this.exampleData(); +@let fileUrl = this.fileUrl(); +
- @if (view === 'snippet') { + @if (view() === 'snippet') {
- @if (showCompactToggle) { + @if (showCompactToggle()) {
- @if (view === 'full') { + @if (view() === 'full') {
- @for (tabName of _getExampleTabNames(); track tabName) { + @for (tabName of _exampleTabNames(); track tabName) {
-
- +
}
@@ -79,10 +82,12 @@ } } - @if (view !== 'snippet') { + @if (view() !== 'snippet') {
- @if (_exampleComponentType && !example?.includes('harness')) { - + @let componentType = _exampleComponentType(); + + @if (componentType && !example?.includes('harness')) { + } @else {
This example contains tests. Open in Stackblitz to run the tests.
} diff --git a/docs/src/app/shared/example-viewer/example-viewer.ts b/docs/src/app/shared/example-viewer/example-viewer.ts index 889a4ba7633a..e8ca23cff8bc 100644 --- a/docs/src/app/shared/example-viewer/example-viewer.ts +++ b/docs/src/app/shared/example-viewer/example-viewer.ts @@ -6,7 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ElementRef, inject, Input, OnInit, Type, viewChildren} from '@angular/core'; +import { + Component, + computed, + ElementRef, + inject, + Input, + model, + signal, + Type, + viewChildren, +} from '@angular/core'; import {MatSnackBar} from '@angular/material/snack-bar'; import {Clipboard} from '@angular/cdk/clipboard'; @@ -47,7 +57,7 @@ const preferredExampleFileOrder = ['HTML', 'TS', 'CSS']; '[attr.id]': 'example', }, }) -export class ExampleViewer implements OnInit { +export class ExampleViewer { private readonly _snackbar = inject(MatSnackBar); private readonly _clipboard = inject(Clipboard); private readonly _elementRef = inject>(ElementRef); @@ -55,25 +65,48 @@ export class ExampleViewer implements OnInit { readonly snippet = viewChildren(CodeSnippet); /** The tab to jump to when expanding from snippet view. */ - selectedTab: number = 0; + readonly selectedTab = signal(0); /** Map of example files that should be displayed in the view-source tab in order. */ - exampleTabs: {[tabName: string]: string} = {}; + readonly exampleTabs = signal>({}); /** Data for the currently selected example. */ - exampleData: LiveExample | null = null; + readonly exampleData = signal(null); /** URL to fetch code snippet for snippet view. */ - fileUrl: string | undefined; + readonly fileUrl = computed(() => { + const file = this.file(); + const exampleData = this.exampleData(); + const region = this.region(); + + if (!file) { + return undefined; + } + + const lastDotIndex = file.lastIndexOf('.'); + const contentBeforeDot = file.substring(0, lastDotIndex); + const contentAfterDot = file.substring(lastDotIndex + 1); + let fileName: string; + + if (region) { + fileName = `${contentBeforeDot}_${region}-${contentAfterDot}.html`; + } else { + fileName = `${contentBeforeDot}-${contentAfterDot}.html`; + } + + return exampleData + ? `/docs-content/examples-highlighted/${exampleData.packagePath}/${fileName}` + : ''; + }); /** Component type for the current example. */ - _exampleComponentType: Type | null = null; + readonly _exampleComponentType = signal | null>(null); /** View of the example component. */ - @Input() view: Views | undefined; + readonly view = model(); /** Whether to show toggle for compact view. */ - @Input() showCompactToggle = false; + readonly showCompactToggle = model(false); /** String key of the currently displayed example. */ @Input() @@ -89,30 +122,26 @@ export class ExampleViewer implements OnInit { private _example: string | undefined; /** Range of lines of the source code to display in compact view. */ - @Input() region?: string; + readonly region = signal(undefined); /** Name of file to display in compact view. */ - @Input() file?: string; - - ngOnInit() { - if (this.file) { - this.fileUrl = this.generateUrl(this.file); - } - } + readonly file = model(); /** Selects a given tab based on the example file of the compact view. */ selectCorrectTab() { - if (!this.file || !this.exampleTabs) { + const file = this.file(); + const exampleTabNames = this._exampleTabNames(); + + if (!file || !exampleTabNames.length) { return; } - const extension = this.file.substring(this.file.lastIndexOf('.') + 1); - const exampleTabNames = this._getExampleTabNames(); + const extension = file.substring(file.lastIndexOf('.') + 1); for (let i = 0; i < exampleTabNames.length; i++) { const tabName = exampleTabNames[i]; if (tabName.toLowerCase() === extension || tabName.endsWith(`.${extension}`)) { - this.selectedTab = i; + this.selectedTab.set(i); return; } } @@ -121,16 +150,16 @@ export class ExampleViewer implements OnInit { } toggleCompactView() { - if (this.view === 'snippet') { - this.view = 'full'; + if (this.view() === 'snippet') { + this.view.set('full'); this.selectCorrectTab(); } else { - this.view = 'snippet'; + this.view.set('snippet'); } } toggleSourceView(): void { - this.view = this.view === 'full' ? 'demo' : 'full'; + this.view.set(this.view() === 'full' ? 'demo' : 'full'); } copySource(snippets: readonly CodeSnippet[], selectedIndex: number = 0) { @@ -142,42 +171,29 @@ export class ExampleViewer implements OnInit { } } - generateUrl(file: string): string { - const lastDotIndex = file.lastIndexOf('.'); - const contentBeforeDot = file.substring(0, lastDotIndex); - const contentAfterDot = file.substring(lastDotIndex + 1); - let fileName: string; + protected _exampleTabNames = computed(() => { + const exampleTabs = this.exampleTabs(); - if (this.region) { - fileName = `${contentBeforeDot}_${this.region}-${contentAfterDot}.html`; - } else { - fileName = `${contentBeforeDot}-${contentAfterDot}.html`; + if (!exampleTabs) { + return []; } - return this.exampleData - ? `/docs-content/examples-highlighted/${this.exampleData.packagePath}/${fileName}` - : ''; - } + return Object.keys(exampleTabs).sort((a, b) => { + let indexA = preferredExampleFileOrder.indexOf(a); + let indexB = preferredExampleFileOrder.indexOf(b); + // Files which are not part of the preferred example file order should be + // moved after all items with a preferred index. + if (indexA === -1) { + indexA = preferredExampleFileOrder.length; + } - _getExampleTabNames() { - return this.exampleTabs - ? Object.keys(this.exampleTabs).sort((a, b) => { - let indexA = preferredExampleFileOrder.indexOf(a); - let indexB = preferredExampleFileOrder.indexOf(b); - // Files which are not part of the preferred example file order should be - // moved after all items with a preferred index. - if (indexA === -1) { - indexA = preferredExampleFileOrder.length; - } - - if (indexB === -1) { - indexB = preferredExampleFileOrder.length; - } - - return indexA - indexB || 1; - }) - : []; - } + if (indexB === -1) { + indexB = preferredExampleFileOrder.length; + } + + return indexA - indexB || 1; + }); + }); _copyLink() { // Reconstruct the URL using `origin + pathname` so we drop any pre-existing hash. @@ -193,19 +209,19 @@ export class ExampleViewer implements OnInit { private async _exampleChanged(name: string) { const examples = (await this._docsItems.getData()).examples; - this.exampleData = examples[name]; + this.exampleData.set(examples[name]); - if (!this.exampleData) { + if (!this.exampleData()) { console.error(`Could not find example: ${name}`); return; } try { - this._generateExampleTabs(); + this._generateExampleTabs(this.exampleData()); // Lazily loads the example package that contains the requested example. const moduleExports = await loadExample(name); - this._exampleComponentType = moduleExports[examples[name].componentName]; + this._exampleComponentType.set(moduleExports[examples[name].componentName]); // Since the data is loaded asynchronously, we can't count on the native behavior // that scrolls the element into view automatically. We do it ourselves while giving @@ -218,20 +234,20 @@ export class ExampleViewer implements OnInit { } } - private _generateExampleTabs() { - this.exampleTabs = {}; + private _generateExampleTabs(data: LiveExample | null) { + const tabs: Record = {}; - if (this.exampleData) { + if (data) { // Name of the default example files. If files with such name exist within the example, // we provide a shorthand for them within the example tabs (for less verbose tabs). const exampleBaseFileName = `${this.example}-example`; - const docsContentPath = `/docs-content/examples-highlighted/${this.exampleData.packagePath}`; + const docsContentPath = `/docs-content/examples-highlighted/${data.packagePath}`; const tsPath = normalizePath(`${exampleBaseFileName}.ts`); const cssPath = normalizePath(`${exampleBaseFileName}.css`); const htmlPath = normalizePath(`${exampleBaseFileName}.html`); - for (let fileName of this.exampleData.files) { + for (let fileName of data.files) { // Since the additional files refer to the original file name, we need to transform // the file name to match the highlighted HTML file that displays the source. const fileSourceName = fileName.replace(fileExtensionRegex, '$1-$2.html'); @@ -242,15 +258,17 @@ export class ExampleViewer implements OnInit { fileName = normalizePath(fileName); if (fileName === tsPath) { - this.exampleTabs['TS'] = importPath; + tabs['TS'] = importPath; } else if (fileName === cssPath) { - this.exampleTabs['CSS'] = importPath; + tabs['CSS'] = importPath; } else if (fileName === htmlPath) { - this.exampleTabs['HTML'] = importPath; + tabs['HTML'] = importPath; } else { - this.exampleTabs[fileName] = importPath; + tabs[fileName] = importPath; } } } + + this.exampleTabs.set(tabs); } }