From 1d3a7529b4fa3617a5d6a97e742cb13818253a14 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 6 Mar 2024 16:42:09 -0800 Subject: [PATCH] feat(router): Set a different browser URL from the one for route matching (#53318) This feature adds a property to the `NavigationBehaviorOptions` that allows developers to define a different path for the browser's address bar than the one used to match routes. This is useful for redirects where you want to keep the browser bar the same as the original attempted navigation but redirect to a different page, such as a 404 or error page. fixes #17004 PR Close #53318 --- goldens/public-api/router/index.api.md | 1 + packages/router/src/models.ts | 35 +++++++++++ packages/router/src/navigation_transition.ts | 18 +++++- packages/router/src/router.ts | 2 +- .../router/src/statemanager/state_manager.ts | 15 ++--- packages/router/test/integration.spec.ts | 63 +++++++++++++++++++ 6 files changed, 123 insertions(+), 11 deletions(-) diff --git a/goldens/public-api/router/index.api.md b/goldens/public-api/router/index.api.md index f903ca6197962..7107d202b74c1 100644 --- a/goldens/public-api/router/index.api.md +++ b/goldens/public-api/router/index.api.md @@ -416,6 +416,7 @@ export interface Navigation { // @public export interface NavigationBehaviorOptions { + readonly browserUrl?: UrlTree | string; readonly info?: unknown; onSameUrlNavigation?: OnSameUrlNavigation; replaceUrl?: boolean; diff --git a/packages/router/src/models.ts b/packages/router/src/models.ts index f8932f3b28d84..6b127c06a31df 100644 --- a/packages/router/src/models.ts +++ b/packages/router/src/models.ts @@ -1499,4 +1499,39 @@ export interface NavigationBehaviorOptions { * when the transition has finished animating. */ readonly info?: unknown; + + /** + * When set, the Router will update the browser's address bar to match the given `UrlTree` instead + * of the one used for route matching. + * + * + * @usageNotes + * + * This feature is useful for redirects, such as redirecting to an error page, without changing + * the value that will be displayed in the browser's address bar. + * + * ``` + * const canActivate: CanActivateFn = (route: ActivatedRouteSnapshot) => { + * const userService = inject(UserService); + * const router = inject(Router); + * if (!userService.isLoggedIn()) { + * const targetOfCurrentNavigation = router.getCurrentNavigation()?.finalUrl; + * const redirect = router.parseUrl('/404'); + * return new RedirectCommand(redirect, {browserUrl: targetOfCurrentNavigation}); + * } + * return true; + * }; + * ``` + * + * This value is used directly, without considering any `UrlHandingStrategy`. In this way, + * `browserUrl` can also be used to use a different value for the browser URL than what would have + * been produced by from the navigation due to `UrlHandlingStrategy.merge`. + * + * This value only affects the path presented in the browser's address bar. It does not apply to + * the internal `Router` state. Information such as `params` and `data` will match the internal + * state used to match routes which will be different from the browser URL when using this feature + * The same is true when using other APIs that cause the browser URL the differ from the Router + * state, such as `skipLocationChange`. + */ + readonly browserUrl?: UrlTree | string; } diff --git a/packages/router/src/navigation_transition.ts b/packages/router/src/navigation_transition.ts index d4848fe5550cc..7510e6742cf7e 100644 --- a/packages/router/src/navigation_transition.ts +++ b/packages/router/src/navigation_transition.ts @@ -266,6 +266,12 @@ export interface Navigation { * It is guaranteed to be set after the `RoutesRecognized` event fires. */ finalUrl?: UrlTree; + /** + * `UrlTree` to use when updating the browser URL for the navigation when `extras.browserUrl` is + * defined. + * @internal + */ + readonly targetBrowserUrl?: UrlTree | string; /** * TODO(atscott): If we want to make StateManager public, they will need access to this. Note that * it's already eventually exposed through router.routerState. @@ -475,6 +481,10 @@ export class NavigationTransitions { id: t.id, initialUrl: t.rawUrl, extractedUrl: t.extractedUrl, + targetBrowserUrl: + typeof t.extras.browserUrl === 'string' + ? this.urlSerializer.parse(t.extras.browserUrl) + : t.extras.browserUrl, trigger: t.source, extras: t.extras, previousNavigation: !this.lastSuccessfulNavigation @@ -955,12 +965,14 @@ export class NavigationTransitions { // The extracted URL is the part of the URL that this application cares about. `extract` may // return only part of the browser URL and that part may have not changed even if some other // portion of the URL did. - const extractedBrowserUrl = this.urlHandlingStrategy.extract( + const currentBrowserUrl = this.urlHandlingStrategy.extract( this.urlSerializer.parse(this.location.path(true)), ); + const targetBrowserUrl = + this.currentNavigation?.targetBrowserUrl ?? this.currentNavigation?.extractedUrl; return ( - extractedBrowserUrl.toString() !== this.currentTransition?.extractedUrl.toString() && - !this.currentTransition?.extras.skipLocationChange + currentBrowserUrl.toString() !== targetBrowserUrl?.toString() && + !this.currentNavigation?.extras.skipLocationChange ); } } diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 665eab460ee52..588a3edd9169b 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -222,7 +222,7 @@ export class Router { currentTransition.currentRawUrl, ); const extras = { - // Persist transient navigation info from the original navigation request. + browserUrl: currentTransition.extras.browserUrl, info: currentTransition.extras.info, skipLocationChange: currentTransition.extras.skipLocationChange, // The URL is already updated at this point if we have 'eager' URL diff --git a/packages/router/src/statemanager/state_manager.ts b/packages/router/src/statemanager/state_manager.ts index da0ab026ada98..054d5d6752bcb 100644 --- a/packages/router/src/statemanager/state_manager.ts +++ b/packages/router/src/statemanager/state_manager.ts @@ -178,7 +178,7 @@ export class HistoryStateManager extends StateManager { currentTransition.finalUrl!, currentTransition.initialUrl, ); - this.setBrowserUrl(rawUrl, currentTransition); + this.setBrowserUrl(currentTransition.targetBrowserUrl ?? rawUrl, currentTransition); } } } else if (e instanceof BeforeActivateRoutes) { @@ -188,10 +188,11 @@ export class HistoryStateManager extends StateManager { currentTransition.initialUrl, ); this.routerState = currentTransition.targetRouterState!; - if (this.urlUpdateStrategy === 'deferred') { - if (!currentTransition.extras.skipLocationChange) { - this.setBrowserUrl(this.rawUrlTree, currentTransition); - } + if (this.urlUpdateStrategy === 'deferred' && !currentTransition.extras.skipLocationChange) { + this.setBrowserUrl( + currentTransition.targetBrowserUrl ?? this.rawUrlTree, + currentTransition, + ); } } else if ( e instanceof NavigationCancel && @@ -207,8 +208,8 @@ export class HistoryStateManager extends StateManager { } } - private setBrowserUrl(url: UrlTree, transition: Navigation) { - const path = this.urlSerializer.serialize(url); + private setBrowserUrl(url: UrlTree | string, transition: Navigation) { + const path = url instanceof UrlTree ? this.urlSerializer.serialize(url) : url; if (this.location.isCurrentPathEqualTo(path) || !!transition.extras.replaceUrl) { // replacements do not update the target page const currentBrowserPageId = this.browserPageId; diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 331851a7d90db..c325e49dc4ad4 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -4029,6 +4029,69 @@ for (const browserAPI of ['navigation', 'history'] as const) { })); }); + it('can redirect to 404 without changing the URL', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'one', + component: RouteCmp, + canActivate: [ + () => { + const router = coreInject(Router); + router.navigateByUrl('/404', { + browserUrl: router.getCurrentNavigation()?.finalUrl, + }); + return false; + }, + ], + }, + {path: '404', component: SimpleCmp}, + ]), + ], + }); + const location = TestBed.inject(Location); + await RouterTestingHarness.create('/one'); + + expect(location.path()).toEqual('/one'); + expect(TestBed.inject(Router).url.toString()).toEqual('/404'); + }); + + it('can navigate to same internal route with different browser url', async () => { + TestBed.configureTestingModule({ + providers: [provideRouter([{path: 'one', component: RouteCmp}])], + }); + const location = TestBed.inject(Location); + const router = TestBed.inject(Router); + await RouterTestingHarness.create('/one'); + await router.navigateByUrl('/one', {browserUrl: '/two'}); + + expect(location.path()).toEqual('/two'); + expect(router.url.toString()).toEqual('/one'); + }); + + it('retains browserUrl through UrlTree redirects', async () => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'one', + component: RouteCmp, + canActivate: [() => coreInject(Router).parseUrl('/404')], + }, + {path: '404', component: SimpleCmp}, + ]), + ], + }); + const router = TestBed.inject(Router); + const location = TestBed.inject(Location); + await RouterTestingHarness.create(); + await router.navigateByUrl('/one', {browserUrl: router.parseUrl('abc123')}); + + expect(location.path()).toEqual('/abc123'); + expect(TestBed.inject(Router).url.toString()).toEqual('/404'); + }); + describe('runGuardsAndResolvers', () => { let guardRunCount = 0; let resolverRunCount = 0;