refactor(router): Add provider for integrating with Navigation API and Location shim

This adds a (private) provider for integrating with the browser Navigation API.
This provider ensures that interactions with the `Location` service
use the underlying platform navigation rather than the history and
location APIs.
This commit is contained in:
Andrew Scott
2025-10-27 08:21:56 +00:00
committed by GitHub
parent b5f8d0b5b1
commit dd09da8ba2
8 changed files with 295 additions and 5 deletions
@@ -0,0 +1,87 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Injectable, inject, DestroyRef} from '@angular/core';
import {PlatformNavigation} from '../navigation/platform_navigation';
import {Location} from './location';
import {LocationStrategy} from './location_strategy';
import {normalizeQueryParams} from './util';
/**
* A `Location` implementation that uses the browser's `Navigation` API.
*
* This class is an adapter that maps the methods of the `Location` service to the newer
* browser `Navigation` API. It is used when the `Navigation` API is available.
*
* This adapter uses `navigation.navigate()` for `go` and `replaceState` to ensure a single source
* of truth for the navigation state. The Navigation API's state and `history.state` are separate.
*
* Note that `navigation.back()` and `navigation.forward()` can differ from the traditional
* `history` API in how they traverse the joint session history.
*
* @see {@link Location}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
*/
@Injectable()
export class NavigationAdapterForLocation extends Location {
private readonly navigation = inject(PlatformNavigation);
private readonly destroyRef = inject(DestroyRef);
constructor() {
super(inject(LocationStrategy));
this.registerNavigationListeners();
}
private registerNavigationListeners() {
const currentEntryChangeListener = () => {
this._notifyUrlChangeListeners(this.path(true), this.getState());
};
this.navigation.addEventListener('currententrychange', currentEntryChangeListener);
this.destroyRef.onDestroy(() => {
this.navigation.removeEventListener('currententrychange', currentEntryChangeListener);
});
}
override getState(): unknown {
return this.navigation.currentEntry?.getState();
}
override replaceState(path: string, query: string = '', state: any = null): void {
const url = this.prepareExternalUrl(path + normalizeQueryParams(query));
// Use navigation API consistently for navigations. The "navigation API state"
// field has no interaction with the existing "serialized state" field, which is what backs history.state
this.navigation.navigate(url, {state, history: 'replace'});
}
override go(path: string, query: string = '', state: any = null): void {
const url = this.prepareExternalUrl(path + normalizeQueryParams(query));
// Use navigation API consistently for navigations. The "navigation API state"
// field has no interaction with the existing "serialized state" field, which is what backs history.state
this.navigation.navigate(url, {state, history: 'push'});
}
// Navigation.back/forward differs from history in how it traverses the joint session history
// https://github.com/WICG/navigation-api?tab=readme-ov-file#correspondence-with-the-joint-session-history
override back() {
this.navigation.back();
}
override forward() {
this.navigation.forward();
}
override onUrlChange(fn: (url: string, state: unknown) => void): VoidFunction {
this._urlChangeListeners.push(fn);
return () => {
const fnIndex = this._urlChangeListeners.indexOf(fn);
this._urlChangeListeners.splice(fnIndex, 1);
};
}
}
+1
View File
@@ -11,3 +11,4 @@ export {
getDOM as ɵgetDOM,
setRootDomAdapter as ɵsetRootDomAdapter,
} from './dom_adapter';
export {NavigationAdapterForLocation as ɵNavigationAdapterForLocation} from './location/navigation_adapter_for_location';
@@ -8,3 +8,4 @@
export {provideFakePlatformNavigation as ɵprovideFakePlatformNavigation} from './navigation/provide_fake_platform_navigation';
export {FakeNavigation as ɵFakeNavigation} from './navigation/fake_navigation';
export {FakeNavigationPlatformLocation as ɵFakeNavigationPlatformLocation} from './mock_platform_location';
+1
View File
@@ -12,3 +12,4 @@ export {loadChildren as ɵloadChildren} from './router_config_loader';
export {ROUTER_PROVIDERS as ɵROUTER_PROVIDERS} from './router_module';
export {afterNextNavigation as ɵafterNextNavigation} from './utils/navigations';
export {provideSometimesSyncRecognize as ɵprovideSometimesSyncRecognize} from './operators/recognize';
export {withPlatformNavigation as ɵwithPlatformNavigation} from './provide_router';
+58 -1
View File
@@ -11,6 +11,8 @@ import {
LOCATION_INITIALIZED,
LocationStrategy,
ViewportScroller,
Location,
ɵNavigationAdapterForLocation,
} from '@angular/common';
import {
APP_BOOTSTRAP_LISTENER,
@@ -26,10 +28,11 @@ import {
provideAppInitializer,
Provider,
runInInjectionContext,
Type,
ɵperformanceMarkFeature as performanceMarkFeature,
ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION as IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
ɵpublishExternalGlobalUtil,
provideEnvironmentInitializer,
Type,
} from '@angular/core';
import {of, Subject} from 'rxjs';
@@ -52,6 +55,8 @@ import {
ViewTransitionsFeatureOptions,
} from './utils/view_transition';
import {getLoadedRoutes, getRouterInstance, navigateByUrl} from './router_devtools';
import {StateManager} from './statemanager/state_manager';
import {NavigationStateManager} from './statemanager/navigation_state_manager';
/**
* Sets up providers necessary to enable `Router` functionality for the application.
@@ -234,6 +239,58 @@ export function withInMemoryScrolling(
return routerFeature(RouterFeatureKind.InMemoryScrollingFeature, providers);
}
/**
* Enables the use of the browser's `History` API for navigation.
*
* @description
* This function provides a `Location` strategy that uses the browser's `History` API.
* It is required when using features that rely on `history.state`. For example, the
* `state` object in `NavigationExtras` is passed to `history.pushState` or
* `history.replaceState`.
*
* @usageNotes
*
* ```typescript
* const appRoutes: Routes = [
* { path: 'page', component: PageComponent },
* ];
*
* bootstrapApplication(AppComponent, {
* providers: [
* provideRouter(appRoutes, withPlatformNavigation())
* ]
* });
* ```
*
* @returns A `RouterFeature` that enables the platform navigation.
*/
export function withPlatformNavigation() {
const devModeLocationCheck =
typeof ngDevMode === 'undefined' || ngDevMode
? [
provideEnvironmentInitializer(() => {
const locationInstance = inject(Location);
if (!(locationInstance instanceof ɵNavigationAdapterForLocation)) {
const locationConstructorName = (locationInstance as any).constructor.name;
let message =
`'withPlatformNavigation' provides a 'Location' implementation that ensures navigation APIs are consistently used.` +
` An instance of ${locationConstructorName} was found instead.`;
if (locationConstructorName === 'SpyLocation') {
message += ` One of 'RouterTestingModule' or 'provideLocationMocks' was likely used. 'withPlatformNavigation' does not work with these because they override the Location implementation.`;
}
throw new Error(message);
}
}),
]
: [];
const providers = [
{provide: StateManager, useExisting: NavigationStateManager},
{provide: Location, useClass: ɵNavigationAdapterForLocation},
devModeLocationCheck,
];
return routerFeature(RouterFeatureKind.InMemoryScrollingFeature, providers);
}
export function getBootstrapListener() {
const injector = inject(Injector);
return (bootstrappedComponentRef: ComponentRef<unknown>) => {
@@ -0,0 +1,48 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {inject, Injectable} from '@angular/core';
import {PlatformNavigation} from '@angular/common';
import {HistoryStateManager} from './state_manager';
import {RestoredState} from '../navigation_transition';
import {NavigationTrigger} from '../events';
import {SubscriptionLike} from 'rxjs';
@Injectable({providedIn: 'root'})
/**
* A `StateManager` that uses the browser's Navigation API to get the state of a `popstate`
* event.
*
* This class is currently an extension of `HistoryStateManager` and is used when the
* Navigation API is available. It overrides the behavior of listening to `popstate` events
* to retrieve the state from `navigation.currentEntry` instead of `history.state` since
* history and navigation states are separate.
*
* This implementation is not complete - it does not integrate at all with navigation API other than
* providing the right state on popstate. It needs to manage the whole lifecycle of the navigation
* by intercepting the navigation event.
*/
export class NavigationStateManager extends HistoryStateManager {
private readonly navigation = inject(PlatformNavigation);
override registerNonRouterCurrentEntryChangeListener(
listener: (
url: string,
state: RestoredState | null | undefined,
trigger: NavigationTrigger,
) => void,
): SubscriptionLike {
return this.location.subscribe((event) => {
if (event['type'] === 'popstate') {
// Pass the state from navigation API rather than from history
const state = this.navigation.currentEntry?.getState() as RestoredState;
listener(event['url']!, state, 'popstate');
}
});
}
}
@@ -7,12 +7,12 @@
*/
import {Location} from '@angular/common';
import {ɵprovideFakePlatformNavigation} from '@angular/common/testing';
import {
ChangeDetectionStrategy,
Component,
NgModule,
ɵConsole as Console,
makeEnvironmentProviders,
signal,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
@@ -35,7 +35,7 @@ import {
RoutesRecognized,
} from '../../index';
import {provideRouter} from '../../src/provide_router';
import {provideRouter, withPlatformNavigation} from '../../src/provide_router';
import {
BlankCmp,
CollectParamsCmp,
@@ -83,8 +83,12 @@ for (const browserAPI of ['navigation', 'history'] as const) {
imports: [...ROUTER_DIRECTIVES, TestModule],
providers: [
{provide: Console, useValue: noopConsole},
provideRouter([{path: 'simple', component: SimpleCmp}]),
browserAPI === 'navigation' ? ɵprovideFakePlatformNavigation() : [],
provideRouter(
[{path: 'simple', component: SimpleCmp}],
browserAPI === 'navigation'
? withPlatformNavigation()
: (makeEnvironmentProviders([]) as any),
),
],
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {TestBed} from '@angular/core/testing';
import {provideRouter} from '../src';
import {withPlatformNavigation} from '../src/provide_router';
import {PlatformLocation, Location, PlatformNavigation} from '@angular/common';
import {
ɵFakeNavigation as FakeNavigation,
ɵFakeNavigationPlatformLocation as FakeNavigationPlatformLocation,
provideLocationMocks,
} from '@angular/common/testing';
/// <reference types="dom-navigation" />
describe('withPlatformNavigation feature', () => {
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideRouter([], withPlatformNavigation())]});
});
it('provides FakeNavigation by default', () => {
expect(TestBed.inject(PlatformNavigation)).toBeInstanceOf(FakeNavigation);
});
it('provides FakeNavigationPlatformLocation by default', () => {
expect(TestBed.inject(PlatformLocation)).toBeInstanceOf(FakeNavigationPlatformLocation);
});
describe('ensures location information is synced with navigation', () => {
let location: Location;
let navigation: PlatformNavigation;
beforeEach(() => {
location = TestBed.inject(Location);
navigation = TestBed.inject(PlatformNavigation);
});
it('state changes via location are reflected in navigation', () => {
location.go('/a', undefined, {someState: 'someValue'});
expect(navigation.currentEntry!.getState()).toEqual(
jasmine.objectContaining({someState: 'someValue'}),
);
});
it('state changes via navigation are reflected in location', () => {
navigation.navigate('/b', {state: {otherState: 'otherValue'}});
expect(location.getState()).toEqual(jasmine.objectContaining({otherState: 'otherValue'}));
});
it('onurlchange tracks changes from navigation API', async () => {
let changed = false;
location.onUrlChange(() => {
changed = true;
});
navigation.navigate('/c');
expect(changed).toBeTrue();
});
it('onurlchange is not synchronous if navigation commit is delayed', async () => {
let changed = false;
location.onUrlChange(() => {
changed = true;
});
navigation.addEventListener('navigate', (e: any) => {
e.intercept({
precommitHandler: () => new Promise((resolve) => setTimeout(resolve)),
});
});
location.go('/c');
expect(changed).toBeFalse();
await new Promise((resolve) => setTimeout(resolve, 1));
expect(changed).toBeTrue();
});
});
});
describe('configuration error', () => {
it('throws an error mentioning SpyLocation and the location mocks', () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withPlatformNavigation()), provideLocationMocks()],
});
expect(() => TestBed.inject(Location)).toThrowError(/SpyLocation.*provideLocationMocks/);
});
});