mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user