From 9228a733631a7d3ba79456c7b2da6e6ff239d4cb Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 2 Apr 2025 21:19:26 -0700 Subject: [PATCH] feat(docs-infra): Add Navigation API adapter to adev (#60722) This adapter adds an integration with the Navigation API, allowing SPA Router navigations to be displayed more fully in the browser UI. With this, site visitors will be able to see that a page is loading via the loading spinner in the tab. They will also have access to cancel the navigation with the browser UI via the "stop" button or by pressing the escape key (only relevant for slower connections). * https://github.com/WICG/navigation-api * https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API PR Close #60722 --- adev/BUILD.bazel | 1 + .../example-viewer.component.spec.ts | 8 +- adev/shared-docs/interfaces/BUILD.bazel | 1 + .../interfaces/docs-content-loader.ts | 2 +- .../core/services/content-loader.service.ts | 44 ++++----- adev/src/app/router_providers.ts | 94 ++++++++++++++++++- adev/tsconfig.app.json | 4 +- adev/tsconfig.json | 2 +- package.json | 1 + yarn.lock | 10 +- 10 files changed, 131 insertions(+), 36 deletions(-) diff --git a/adev/BUILD.bazel b/adev/BUILD.bazel index 863ae78e584..bc8cdab22cf 100644 --- a/adev/BUILD.bazel +++ b/adev/BUILD.bazel @@ -58,6 +58,7 @@ APPLICATION_DEPS = [ "@npm//@angular/platform-server", "@npm//@angular/router", "@npm//@angular/ssr", + "@npm//@types/dom-navigation", "@npm//marked", "@npm//ngx-progressbar", "@npm//rxjs", diff --git a/adev/shared-docs/components/viewers/example-viewer/example-viewer.component.spec.ts b/adev/shared-docs/components/viewers/example-viewer/example-viewer.component.spec.ts index 6947c003a5b..c774d6f400e 100644 --- a/adev/shared-docs/components/viewers/example-viewer/example-viewer.component.spec.ts +++ b/adev/shared-docs/components/viewers/example-viewer/example-viewer.component.spec.ts @@ -8,8 +8,8 @@ import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing'; import {ExampleViewer} from './example-viewer.component'; -import {DocsContentLoader, ExampleMetadata, ExampleViewerContentLoader} from '../../../interfaces'; -import {DOCS_CONTENT_LOADER, EXAMPLE_VIEWER_CONTENT_LOADER} from '../../../providers'; +import {ExampleMetadata, ExampleViewerContentLoader} from '../../../interfaces'; +import {EXAMPLE_VIEWER_CONTENT_LOADER} from '../../../providers'; import {Component, provideExperimentalZonelessChangeDetection, ComponentRef} from '@angular/core'; import {HarnessLoader} from '@angular/cdk/testing'; import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed'; @@ -26,12 +26,9 @@ describe('ExampleViewer', () => { let loader: HarnessLoader; let exampleContentSpy: jasmine.SpyObj; - let contentServiceSpy: jasmine.SpyObj; beforeEach(() => { exampleContentSpy = jasmine.createSpyObj('ExampleContentLoader', ['loadPreview']); - contentServiceSpy = jasmine.createSpyObj('ContentLoader', ['getContent']); - contentServiceSpy.getContent.and.returnValue(Promise.resolve(undefined)); }); beforeEach(async () => { @@ -41,7 +38,6 @@ describe('ExampleViewer', () => { // TODO: Find why tests warn that zone.js is still loaded provideExperimentalZonelessChangeDetection(), {provide: EXAMPLE_VIEWER_CONTENT_LOADER, useValue: exampleContentSpy}, - {provide: DOCS_CONTENT_LOADER, useValue: contentServiceSpy}, {provide: ActivatedRoute, useValue: {snapshot: {fragment: 'fragment'}}}, ], }).compileComponents(); diff --git a/adev/shared-docs/interfaces/BUILD.bazel b/adev/shared-docs/interfaces/BUILD.bazel index 12b42f0a15a..a3e17437296 100644 --- a/adev/shared-docs/interfaces/BUILD.bazel +++ b/adev/shared-docs/interfaces/BUILD.bazel @@ -26,6 +26,7 @@ ts_library( ), deps = [ "//packages/core", + "//packages/router", "@npm//@types/node", "@npm//@webcontainer/api", ], diff --git a/adev/shared-docs/interfaces/docs-content-loader.ts b/adev/shared-docs/interfaces/docs-content-loader.ts index 6f4ac99ab64..65025f3885c 100644 --- a/adev/shared-docs/interfaces/docs-content-loader.ts +++ b/adev/shared-docs/interfaces/docs-content-loader.ts @@ -10,5 +10,5 @@ import {DocContent} from './doc-content'; /** The service responsible for fetching static content for docs pages */ export interface DocsContentLoader { - getContent(path: string): Promise; + getContent(path: string): Promise; } diff --git a/adev/src/app/core/services/content-loader.service.ts b/adev/src/app/core/services/content-loader.service.ts index 01ba2e8a354..ac08bd695e0 100644 --- a/adev/src/app/core/services/content-loader.service.ts +++ b/adev/src/app/core/services/content-loader.service.ts @@ -6,40 +6,42 @@ * found in the LICENSE file at https://angular.dev/license */ -import {HttpClient} from '@angular/common/http'; +import {HttpClient, HttpErrorResponse} from '@angular/common/http'; import {Injectable, inject} from '@angular/core'; import {DocContent, DocsContentLoader} from '@angular/docs'; -import {Router} from '@angular/router'; -import {firstValueFrom, of} from 'rxjs'; -import {catchError, map} from 'rxjs/operators'; +import {firstValueFrom} from 'rxjs'; +import {map} from 'rxjs/operators'; @Injectable() export class ContentLoader implements DocsContentLoader { - private readonly cache = new Map>(); + private readonly cache = new Map>(); private readonly httpClient = inject(HttpClient); - private readonly router = inject(Router); - async getContent(path: string): Promise { + async getContent(path: string): Promise { // If the path does not end with a file extension, add `.md.html` as the default if (!path.match(/\.\w+$/)) { path += '.md.html'; } - if (!this.cache.has(path)) { - try { - this.cache.set( - path, - firstValueFrom( - this.httpClient - .get(`assets/content/${path}`, { - responseType: 'text', - }) - .pipe(map((contents) => ({contents, id: path}))), - ), + try { + let promise = this.cache.get(path); + if (!promise) { + promise = firstValueFrom( + this.httpClient + .get(`assets/content/${path}`, { + responseType: 'text', + }) + .pipe(map((contents) => ({contents, id: path}))), ); - } catch { - this.router.navigateByUrl('/404'); + this.cache.set(path, promise); } + return await promise; + } catch (e) { + const errorResponse = e as HttpErrorResponse; + if (!(e instanceof HttpErrorResponse) || errorResponse.status !== 404) { + // assume 404 errors are permanent but don't cache others that may be temporary + this.cache.delete(path); + } + throw e; } - return this.cache.get(path)!; } } diff --git a/adev/src/app/router_providers.ts b/adev/src/app/router_providers.ts index fb748becfab..987bf5f3c3f 100644 --- a/adev/src/app/router_providers.ts +++ b/adev/src/app/router_providers.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {inject, provideEnvironmentInitializer} from '@angular/core'; +import {DOCUMENT, inject, provideEnvironmentInitializer} from '@angular/core'; import { provideRouter, withInMemoryScrolling, @@ -16,18 +16,38 @@ import { withComponentInputBinding, RouteReuseStrategy, TitleStrategy, + NavigationCancel, + NavigationError, + NavigationStart, + RedirectCommand, + withNavigationErrorHandler, + withRouterConfig, } from '@angular/router'; import {routes} from './routes'; import {ADevTitleStrategy} from './core/services/a-dev-title-strategy'; import {ReuseTutorialsRouteStrategy} from './features/tutorial/tutorials-route-reuse-strategy'; import {AppScroller} from './app-scroller'; +import {Subject} from 'rxjs/internal/Subject'; +import {HttpErrorResponse} from '@angular/common/http'; +import {WINDOW} from '@angular/docs'; +import {merge, map} from 'rxjs'; +const transitionCreated = new Subject(); export const routerProviders = [ provideRouter( routes, withInMemoryScrolling(), + withRouterConfig({canceledNavigationResolution: 'computed'}), + withNavigationErrorHandler(({error}) => { + if (error instanceof HttpErrorResponse) { + // TODO: Redirect to different pages on different response codes? (e.g. 500 page) + return new RedirectCommand(inject(Router).parseUrl('/404')); + } + return void 0; + }), withViewTransitions({ onViewTransitionCreated: ({transition, to}) => { + transitionCreated.next(); const router = inject(Router); const toTree = createUrlTreeFromSnapshot(to, []); // Skip the transition if the only thing changing is the fragment and queryParams @@ -51,4 +71,76 @@ export const routerProviders = [ }, {provide: TitleStrategy, useClass: ADevTitleStrategy}, provideEnvironmentInitializer(() => inject(AppScroller)), + provideEnvironmentInitializer(() => initializeNavigationAdapter()), ]; + +/** + * This function creates an adapter for the Router which creates a browser navigation + * event for any Router navigations (indicated by NavigationStart). This navigation + * is then cancelled right before the Router would commit the change to the browser + * state through history.[push/replace]State (happens right after view transition is created) + * or when the navigation ends without completing (NavigationCancel or NavigationError). + * + * In addition, it listens for the 'navigateerror' event, which would happen if the + * user cancels the navigation using the stop button in the browser UI, pressing the escape key, + * or initiates a document traversal (e.g. browser back/forward button). When this event + * happens, it aborts any ongoing Router navigation. + * + * The benefit we get out of this is that the browser can better indicate a navigation is happening + * when we use the Navigation API. A loading indicator appears on the tab (in desktop chrome) and the + * refresh button changes to an "x" for stop. Site visitors can cancel the navigation using the stop + * button or the escape key (again, on desktop). + */ +const initializeNavigationAdapter = () => { + const router = inject(Router); + const window = inject(WINDOW); + const navigation = window.navigation; + if (!navigation || !inject(DOCUMENT).startViewTransition) { + return; + } + + let intercept = false; + let clearNavigation: (() => void) | undefined; + navigation.addEventListener('navigateerror', async () => { + if (!clearNavigation) { + return; + } + clearNavigation = undefined; + router.getCurrentNavigation()?.abort(); + }); + navigation.addEventListener('navigate', (navigateEvent) => { + if (!intercept) { + return; + } + navigateEvent.intercept({ + handler: () => + new Promise((_, reject) => { + clearNavigation = () => { + clearNavigation = undefined; + reject(); + }; + }), + }); + }); + + merge(transitionCreated.pipe(map(() => 'viewtransition')), router.events).subscribe((e) => { + // Skip this for popstate/traversals that are already committed. + // The rollback is problematic so we only do it for navigations that + // defer the actual update (pushState) on the browser. + if (router.getCurrentNavigation()?.trigger === 'popstate') { + return; + } + if (e instanceof NavigationStart) { + intercept = true; + window.history.replaceState(window.history.state, '', window.location.href); + intercept = false; + } else if ( + // viewtransition happens before NavigateEnd + e === 'viewtransition' || + e instanceof NavigationCancel || + e instanceof NavigationError + ) { + clearNavigation?.(); + } + }); +}; diff --git a/adev/tsconfig.app.json b/adev/tsconfig.app.json index e8f2db9da6e..15f2b2750da 100644 --- a/adev/tsconfig.app.json +++ b/adev/tsconfig.app.json @@ -3,8 +3,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/app", - "types": ["node"] + "types": ["node", "dom-navigation"] }, "files": ["src/main.ts", "src/main.server.ts"], - "include": ["src/**/*.d.ts", "../../node_modules/@types/dom-navigation/index.d.ts"] + "include": ["src/**/*.d.ts"] } diff --git a/adev/tsconfig.json b/adev/tsconfig.json index a8ecd4701a2..9e3942fa34a 100644 --- a/adev/tsconfig.json +++ b/adev/tsconfig.json @@ -21,7 +21,7 @@ "target": "ES2022", "module": "ES2022", "lib": ["ES2022", "dom"], - "types": ["node"], + "types": ["node", "dom-navigation"], "esModuleInterop": true, "resolveJsonModule": true, "jsx": "react", diff --git a/package.json b/package.json index f2ba5c0ff17..a77e3f5f10d 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/convert-source-map": "^2.0.0", "@types/diff": "^7.0.0", "@types/dom-view-transitions": "^1.0.1", + "@types/dom-navigation": "^1.0.5", "@types/hammerjs": "2.0.46", "@types/jasmine": "^5.0.0", "@types/jasmine-ajax": "^3.3.1", diff --git a/yarn.lock b/yarn.lock index 1ac70c43dbb..1046426330b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -317,7 +317,6 @@ "@angular/build-tooling@https://github.com/angular/dev-infra-private-build-tooling-builds.git#ce04ec6cf7604014191821a637e60964a1a3bb4a": version "0.0.0-2670abf637fa155971cdd1f7e570a7f234922a65" - uid ce04ec6cf7604014191821a637e60964a1a3bb4a resolved "https://github.com/angular/dev-infra-private-build-tooling-builds.git#ce04ec6cf7604014191821a637e60964a1a3bb4a" dependencies: "@angular/benchpress" "0.3.0" @@ -473,7 +472,6 @@ "@angular/ng-dev@https://github.com/angular/dev-infra-private-ng-dev-builds.git#55e081cc1c25a00ab203fd39c2f7edd62a91ac3f": version "0.0.0-f172f3f62ce52d6e91f63b8fd6f3ed83df157aa5" - uid "55e081cc1c25a00ab203fd39c2f7edd62a91ac3f" resolved "https://github.com/angular/dev-infra-private-ng-dev-builds.git#55e081cc1c25a00ab203fd39c2f7edd62a91ac3f" dependencies: "@google-cloud/spanner" "7.19.1" @@ -4326,6 +4324,11 @@ resolved "https://registry.yarnpkg.com/@types/diff/-/diff-7.0.2.tgz#d638edebf3c97aa4962b6f1164a7921ab3de9f83" integrity sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q== +"@types/dom-navigation@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/dom-navigation/-/dom-navigation-1.0.5.tgz#5fd6ed014d3ee66b53b0733b6c50994c105b542d" + integrity sha512-aM1Mr488jX62+6b9zdEtPHFnI7ILgvbII4BaBJMaqhhbDBupJJJ8+nXFVyQT1ptujOUAg6Sojkjk3j6rU73Bwg== + "@types/dom-view-transitions@^1.0.1": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/dom-view-transitions/-/dom-view-transitions-1.0.6.tgz#e6c53f17826be079fafe6df03623f37fcd1b86ac" @@ -8167,8 +8170,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: domelementtype "^2.3.0" "domino@https://github.com/angular/domino.git#8f228f8862540c6ccd14f76b5a1d9bb5458618af": - version "2.1.6+git" - uid "8f228f8862540c6ccd14f76b5a1d9bb5458618af" + version "2.1.6" resolved "https://github.com/angular/domino.git#8f228f8862540c6ccd14f76b5a1d9bb5458618af" dompurify@^3.2.4: