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: