feat(router): Publish Router's integration with platform Navigation API as experimental

This publishes the work that was done to integrate with the Navigation
API as an experimental router feature. Browser support is limited and in
active development. There are also known bugs in the browser implementations
and only Chromium browsers supported deferred URL updates with the
`precommitHandler`. Relates to #53321, which I would likely not mark as
completed until this is at least in dev preview, which likely won't
happen until it is widely available and potentially delayed until
`precommitHandler` is widely available as well.

The final form of this api might not even be a "router feature" in the end, but instead be
something similar to what other frameworks have to provide different
platform integrations (e.g. `provideNavigationRouter`). That would
support omitting the history-based integration from the bundle when only
the navigation integration is used. Alternatively, the current
`provideRouter` could require one of `withHistory` or `withPlatformNavigation`.
This commit is contained in:
Andrew Scott 2025-12-17 11:03:11 -08:00 committed by Jessica Janiuk
parent 0ad3adc7c6
commit 7003e8d241
7 changed files with 57 additions and 22 deletions

View file

@ -801,7 +801,7 @@ export interface RouterFeature<FeatureKind extends RouterFeatureKind> {
}
// @public
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature;
export type RouterFeatures = PreloadingFeature | DebugTracingFeature | InitialNavigationFeature | InMemoryScrollingFeature | RouterConfigurationFeature | NavigationErrorHandlerFeature | ComponentInputBindingFeature | ViewTransitionsFeature | ExperimentalAutoCleanupInjectorsFeature | RouterHashLocationFeature | ExperimentalPlatformNavigationFeature;
// @public
export type RouterHashLocationFeature = RouterFeature<RouterFeatureKind.RouterHashLocationFeature>;
@ -1144,6 +1144,9 @@ export function withEnabledBlockingInitialNavigation(): EnabledBlockingInitialNa
// @public
export function withExperimentalAutoCleanupInjectors(): ExperimentalAutoCleanupInjectorsFeature;
// @public
export function withExperimentalPlatformNavigation(): ExperimentalPlatformNavigationFeature;
// @public
export function withHashLocation(): RouterHashLocationFeature;

View file

@ -82,6 +82,7 @@ export {
NavigationErrorHandlerFeature,
PreloadingFeature,
provideRouter,
withExperimentalPlatformNavigation,
provideRoutes,
RouterConfigurationFeature,
RouterFeature,

View file

@ -11,4 +11,3 @@ export {RestoredState as ɵRestoredState} from './navigation_transition';
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 {withPlatformNavigation as ɵwithPlatformNavigation} from './provide_router';

View file

@ -237,13 +237,36 @@ export function withInMemoryScrolling(
}
/**
* Enables the use of the browser's `History` API for navigation.
* A type alias for providers returned by `withExperimentalPlatformNavigation` for use with `provideRouter`.
*
* @see {@link withExperimentalPlatformNavigation}
* @see {@link provideRouter}
*
* @experimental 21.1
*/
export type ExperimentalPlatformNavigationFeature =
RouterFeature<RouterFeatureKind.ExperimentalPlatformNavigationFeature>;
/**
* Enables interop with the browser's `Navigation` API for router navigations.
*
* @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`.
*
* CRITICAL: This feature is _highly_ experimental and should not be used in production. Browser support
* is limited and in active development. Use only for experimentation and feedback purposes.
*
* This function provides a `Location` strategy that uses the browser's `Navigation` API.
* By using the platform's Navigation APIs, the Router is able to provide native
* browser navigation capabilities. Some advantages include:
*
* - The ability to intercept navigations triggered outside the Router. This allows plain anchor
* elements _without_ `RouterLink` directives to be intercepted by the Router and converted to SPA navigations.
* - Native scroll and focus restoration support by the browser, without the need for custom implementations.
* - Communication of ongoing navigations to the browser, enabling built-in features like
* accessibility announcements, loading indicators, stop buttons, and performance measurement APIs.
* NOTE: Deferred entry updates are not part of the interop 2025 Navigation API commitments so the "ongoing navigation"
* communication support is limited.
*
* @usageNotes
*
@ -254,14 +277,19 @@ export function withInMemoryScrolling(
*
* bootstrapApplication(AppComponent, {
* providers: [
* provideRouter(appRoutes, withPlatformNavigation())
* provideRouter(appRoutes, withExperimentalPlatformNavigation())
* ]
* });
* ```
*
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#problem-statement
* @see https://developer.chrome.com/docs/web-platform/navigation-api/
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
*
* @experimental 21.1
* @returns A `RouterFeature` that enables the platform navigation.
*/
export function withPlatformNavigation() {
export function withExperimentalPlatformNavigation(): ExperimentalPlatformNavigationFeature {
const devModeLocationCheck =
typeof ngDevMode === 'undefined' || ngDevMode
? [
@ -270,10 +298,10 @@ export function withPlatformNavigation() {
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.` +
`'withExperimentalPlatformNavigation' 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.`;
message += ` One of 'RouterTestingModule' or 'provideLocationMocks' was likely used. 'withExperimentalPlatformNavigation' does not work with these because they override the Location implementation.`;
}
throw new Error(message);
}
@ -285,7 +313,7 @@ export function withPlatformNavigation() {
{provide: Location, useClass: ɵNavigationAdapterForLocation},
devModeLocationCheck,
];
return routerFeature(RouterFeatureKind.InMemoryScrollingFeature, providers);
return routerFeature(RouterFeatureKind.ExperimentalPlatformNavigationFeature, providers);
}
export function getBootstrapListener() {
@ -910,7 +938,8 @@ export type RouterFeatures =
| ComponentInputBindingFeature
| ViewTransitionsFeature
| ExperimentalAutoCleanupInjectorsFeature
| RouterHashLocationFeature;
| RouterHashLocationFeature
| ExperimentalPlatformNavigationFeature;
/**
* The list of features as an enum to uniquely type each feature.
@ -927,4 +956,5 @@ export const enum RouterFeatureKind {
ComponentInputBindingFeature,
ViewTransitionsFeature,
ExperimentalAutoCleanupInjectorsFeature,
ExperimentalPlatformNavigationFeature,
}

View file

@ -13,7 +13,7 @@ import {expect} from '@angular/private/testing/matchers';
import {Router, RouterModule, RouterOutlet, UrlTree, withRouterConfig} from '../index';
import {EMPTY, of} from 'rxjs';
import {provideRouter, withPlatformNavigation} from '../src/provide_router';
import {provideRouter, withExperimentalPlatformNavigation} from '../src/provide_router';
import {isUrlTree} from '../src/url_tree';
import {timeout, useAutoTick} from './helpers';
import {afterNextNavigation} from '../src/utils/navigations';
@ -127,7 +127,7 @@ for (const browserAPI of ['navigation', 'history'] as const) {
resolveNavigationPromiseOnError: true,
}),
browserAPI === 'navigation'
? withPlatformNavigation()
? withExperimentalPlatformNavigation()
: (makeEnvironmentProviders([]) as any),
),
],

View file

@ -35,7 +35,7 @@ import {
RoutesRecognized,
} from '../../index';
import {provideRouter, withPlatformNavigation} from '../../src/provide_router';
import {provideRouter, withExperimentalPlatformNavigation} from '../../src/provide_router';
import {
BlankCmp,
CollectParamsCmp,
@ -87,7 +87,7 @@ for (const browserAPI of ['navigation', 'history'] as const) {
provideRouter(
[{path: 'simple', component: SimpleCmp}],
browserAPI === 'navigation'
? withPlatformNavigation()
? withExperimentalPlatformNavigation()
: (makeEnvironmentProviders([]) as any),
),
],

View file

@ -8,7 +8,7 @@
import {TestBed} from '@angular/core/testing';
import {provideRouter, Router} from '../src';
import {withPlatformNavigation, withRouterConfig} from '../src/provide_router';
import {withExperimentalPlatformNavigation, withRouterConfig} from '../src/provide_router';
import {withBody} from '@angular/private/testing';
import {
PlatformLocation,
@ -27,7 +27,9 @@ import {timeout, useAutoTick} from './helpers';
describe('withPlatformNavigation feature', () => {
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideRouter([], withPlatformNavigation())]});
TestBed.configureTestingModule({
providers: [provideRouter([], withExperimentalPlatformNavigation())],
});
});
it('provides FakeNavigation by default', () => {
@ -170,7 +172,7 @@ describe('withPlatformNavigation feature', () => {
providers: [
provideRouter(
[{path: '**', children: []}],
withPlatformNavigation(),
withExperimentalPlatformNavigation(),
withRouterConfig({urlUpdateStrategy: 'eager'}),
),
],
@ -203,7 +205,7 @@ describe('withPlatformNavigation feature', () => {
describe('configuration error', () => {
it('throws an error mentioning SpyLocation and the location mocks', () => {
TestBed.configureTestingModule({
providers: [provideRouter([], withPlatformNavigation()), provideLocationMocks()],
providers: [provideRouter([], withExperimentalPlatformNavigation()), provideLocationMocks()],
});
expect(() => TestBed.inject(Location)).toThrowError(/SpyLocation.*provideLocationMocks/);
});
@ -215,7 +217,7 @@ if (typeof window !== 'undefined' && 'navigation' in window) {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter([{path: '**', children: []}], withPlatformNavigation()),
provideRouter([{path: '**', children: []}], withExperimentalPlatformNavigation()),
{provide: PlatformLocation, useClass: BrowserPlatformLocation},
{provide: PlatformNavigation, useFactory: () => navigation},
],