diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index 38f06947a49c..dfc739595c6f 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -218,6 +218,9 @@ export type RestoredState = { // The `ɵ` prefix is there to reduce the chance of colliding with any existing user properties on // the history state. ɵrouterPageId?: number; + // When `browserUrl` is used, the actual route URL is stored here so that popstate events + // can use it for route matching instead of the displayed browser URL. + ɵrouterUrl?: string; }; /** diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 9f1c755d5743..27788fed3117 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -318,18 +318,27 @@ export class Router { // position for the page. const restoredState = state?.navigationId ? state : null; + // When `browserUrl` was used during the original navigation, the actual route URL + // was stored in history state as `ɵrouterUrl`. Use it for route matching and + // preserve the browser URL as the displayed URL. + const routerUrl = state?.ɵrouterUrl ?? url; + if (state?.ɵrouterUrl) { + extras = {...extras, browserUrl: url}; + } + // Separate to NavigationStart.restoredState, we must also restore the state to // history.state and generate a new navigationId, since it will be overwritten if (state) { const stateCopy = {...state} as Partial; delete stateCopy.navigationId; delete stateCopy.ɵrouterPageId; + delete stateCopy.ɵrouterUrl; if (Object.keys(stateCopy).length !== 0) { extras.state = stateCopy; } } - const urlTree = this.parseUrl(url); + const urlTree = this.parseUrl(routerUrl); this.scheduleNavigation(urlTree, source, restoredState, extras).catch((e) => { if (this.disposed) { return; diff --git a/packages/router/src/statemanager/navigation_state_manager.ts b/packages/router/src/statemanager/navigation_state_manager.ts index f7de8a7768b6..4db2ad85ce04 100644 --- a/packages/router/src/statemanager/navigation_state_manager.ts +++ b/packages/router/src/statemanager/navigation_state_manager.ts @@ -268,8 +268,7 @@ export class NavigationStateManager extends StateManager { // Prepare the state to be stored in the NavigationHistoryEntry. const state = { ...transition.extras.state, - // Include router's navigationId for tracking. Required for in-memory scroll restoration - navigationId: transition.id, + ...this.generateNgRouterState(transition), }; const info: NavigationInfo = {ɵrouterInfo: {intercept: true}}; @@ -489,7 +488,7 @@ export class NavigationStateManager extends StateManager { : 'push'; const state = { ...transition.extras.state, - navigationId: transition.id, + ...this.generateNgRouterState(transition), }; // this might be a path or an actual URL depending on the baseHref const pathOrUrl = this.location.prepareExternalUrl(internalPath); @@ -544,6 +543,14 @@ export class NavigationStateManager extends StateManager { return new URL(routerDestination, eventDestination.origin).href === eventDestination.href; } + private generateNgRouterState(transition: RouterNavigation) { + return { + ...this.routerUrlState(transition), + // Include router's navigationId for tracking. Required for in-memory scroll restoration + navigationId: transition.id, + }; + } + private deferredCommitSupported(event: NavigateEvent): boolean { return ( this.precommitHandlerSupported && diff --git a/packages/router/src/statemanager/state_manager.ts b/packages/router/src/statemanager/state_manager.ts index 3f9eed8eb404..a8f96f8c8ef7 100644 --- a/packages/router/src/statemanager/state_manager.ts +++ b/packages/router/src/statemanager/state_manager.ts @@ -91,6 +91,15 @@ export abstract class StateManager { return path; } + protected routerUrlState(navigation?: Navigation): { + ɵrouterUrl?: string; + } { + if (navigation?.targetBrowserUrl === undefined || navigation?.finalUrl === undefined) { + return {}; + } + return {ɵrouterUrl: this.urlSerializer.serialize(navigation.finalUrl)}; + } + protected commitTransition({targetRouterState, finalUrl, initialUrl}: Navigation): void { // If we are committing the transition after having a final URL and target state, we're updating // all pieces of the state. Otherwise, we likely skipped the transition (due to URL handling strategy) @@ -226,20 +235,22 @@ export class HistoryStateManager extends StateManager { } } - private setBrowserUrl(path: string, {extras, id}: Navigation) { + private setBrowserUrl(path: string, navigation: Navigation) { + const {extras, id} = navigation; const {replaceUrl, state} = extras; + if (this.location.isCurrentPathEqualTo(path) || !!replaceUrl) { // replacements do not update the target page const currentBrowserPageId = this.browserPageId; const newState = { ...state, - ...this.generateNgRouterState(id, currentBrowserPageId), + ...this.generateNgRouterState(id, currentBrowserPageId, navigation), }; this.location.replaceState(path, '', newState); } else { const newState = { ...state, - ...this.generateNgRouterState(id, this.browserPageId + 1), + ...this.generateNgRouterState(id, this.browserPageId + 1, navigation), }; this.location.go(path, '', newState); } @@ -299,10 +310,15 @@ export class HistoryStateManager extends StateManager { ); } - private generateNgRouterState(navigationId: number, routerPageId: number) { + private generateNgRouterState( + navigationId: number, + routerPageId: number, + navigation?: Navigation, + ) { if (this.canceledNavigationResolution === 'computed') { - return {navigationId, ɵrouterPageId: routerPageId}; + return {navigationId, ɵrouterPageId: routerPageId, ...this.routerUrlState(navigation)}; } - return {navigationId}; + + return {navigationId, ...this.routerUrlState(navigation)}; } } diff --git a/packages/router/test/integration/integration.spec.ts b/packages/router/test/integration/integration.spec.ts index 8a593c0e12b9..3ec1cbece857 100644 --- a/packages/router/test/integration/integration.spec.ts +++ b/packages/router/test/integration/integration.spec.ts @@ -73,6 +73,7 @@ import {eagerUrlUpdateStrategyIntegrationSuite} from './eager_url_update_strateg import {duplicateInFlightNavigationsIntegrationSuite} from './duplicate_in_flight_navigations.spec'; import {navigationErrorsIntegrationSuite} from './navigation_errors.spec'; import {useAutoTick} from '@angular/private/testing'; +import {RouterTestingHarness} from '../../testing'; for (const browserAPI of ['navigation', 'history'] as const) { describe(`${browserAPI}-based routing`, () => { @@ -393,6 +394,33 @@ for (const browserAPI of ['navigation', 'history'] as const) { expect(event!.restoredState!.navigationId).toEqual(userVictorNavStart.id); }); + it('should restore internal route on popstate when browserUrl is used', async () => { + const router: Router = TestBed.inject(Router); + const location: Location = TestBed.inject(Location); + + router.resetConfig([ + {path: 'home', component: SimpleCmp}, + {path: 'one', component: SimpleCmp}, + ]); + + const harness = await RouterTestingHarness.create('/home'); + router.setUpLocationChangeListener(); + + await router.navigateByUrl('/one', {browserUrl: '/display-one'}); + expect(location.path()).toEqual('/display-one'); + expect(router.url).toEqual('/one'); + + location.back(); + await advance(harness.fixture); + expect(location.path()).toEqual('/home'); + expect(router.url).toEqual('/home'); + + location.forward(); + await advance(harness.fixture); + expect(router.url).toEqual('/one'); + expect(location.path()).toEqual('/display-one'); + }); + it('should navigate to the same url when config changes', async () => { const router: Router = TestBed.inject(Router); const location: Location = TestBed.inject(Location);