mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<ExampleViewerContentLoader>;
|
||||
let contentServiceSpy: jasmine.SpyObj<DocsContentLoader>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -26,6 +26,7 @@ ts_library(
|
||||
),
|
||||
deps = [
|
||||
"//packages/core",
|
||||
"//packages/router",
|
||||
"@npm//@types/node",
|
||||
"@npm//@webcontainer/api",
|
||||
],
|
||||
|
||||
@@ -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<DocContent | undefined>;
|
||||
getContent(path: string): Promise<DocContent>;
|
||||
}
|
||||
|
||||
@@ -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<string, Promise<DocContent | undefined>>();
|
||||
private readonly cache = new Map<string, Promise<DocContent>>();
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async getContent(path: string): Promise<DocContent | undefined> {
|
||||
async getContent(path: string): Promise<DocContent> {
|
||||
// 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)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>();
|
||||
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<void>((_, 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?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "dom"],
|
||||
"types": ["node"],
|
||||
"types": ["node", "dom-navigation"],
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"jsx": "react",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user