diff --git a/packages/common/src/location/navigation_adapter_for_location.ts b/packages/common/src/location/navigation_adapter_for_location.ts new file mode 100644 index 00000000000..4dc337f8d4c --- /dev/null +++ b/packages/common/src/location/navigation_adapter_for_location.ts @@ -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); + }; + } +} diff --git a/packages/common/src/private_export.ts b/packages/common/src/private_export.ts index 0fcb9cbaa00..a3068a4750b 100644 --- a/packages/common/src/private_export.ts +++ b/packages/common/src/private_export.ts @@ -11,3 +11,4 @@ export { getDOM as ɵgetDOM, setRootDomAdapter as ɵsetRootDomAdapter, } from './dom_adapter'; +export {NavigationAdapterForLocation as ɵNavigationAdapterForLocation} from './location/navigation_adapter_for_location'; diff --git a/packages/common/testing/src/private_export.ts b/packages/common/testing/src/private_export.ts index b1f4c653775..27dc1b4108b 100644 --- a/packages/common/testing/src/private_export.ts +++ b/packages/common/testing/src/private_export.ts @@ -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'; diff --git a/packages/router/src/private_export.ts b/packages/router/src/private_export.ts index 1386e5d7d8e..e3ae79cbbcc 100644 --- a/packages/router/src/private_export.ts +++ b/packages/router/src/private_export.ts @@ -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'; diff --git a/packages/router/src/provide_router.ts b/packages/router/src/provide_router.ts index e93b0c7e26e..84ddb553f40 100644 --- a/packages/router/src/provide_router.ts +++ b/packages/router/src/provide_router.ts @@ -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) => { diff --git a/packages/router/src/statemanager/navigation_state_manager.ts b/packages/router/src/statemanager/navigation_state_manager.ts new file mode 100644 index 00000000000..8bf5d3b410d --- /dev/null +++ b/packages/router/src/statemanager/navigation_state_manager.ts @@ -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'); + } + }); + } +} diff --git a/packages/router/test/integration/integration.spec.ts b/packages/router/test/integration/integration.spec.ts index 86c7c00de25..7d0abe2610e 100644 --- a/packages/router/test/integration/integration.spec.ts +++ b/packages/router/test/integration/integration.spec.ts @@ -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), + ), ], }); }); diff --git a/packages/router/test/with_platform_navigation.spec.ts b/packages/router/test/with_platform_navigation.spec.ts new file mode 100644 index 00000000000..a6bd0612da3 --- /dev/null +++ b/packages/router/test/with_platform_navigation.spec.ts @@ -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'; + +/// + +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/); + }); +});