Skip to content

Commit

Permalink
feat(router): Set a different browser URL from the one for route matc…
Browse files Browse the repository at this point in the history
…hing (angular#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 angular#17004

PR Close angular#53318
  • Loading branch information
atscott authored and AndrewKushnir committed Jun 13, 2024
1 parent fca5764 commit 1d3a752
Show file tree
Hide file tree
Showing 6 changed files with 123 additions and 11 deletions.
1 change: 1 addition & 0 deletions goldens/public-api/router/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ export interface Navigation {

// @public
export interface NavigationBehaviorOptions {
readonly browserUrl?: UrlTree | string;
readonly info?: unknown;
onSameUrlNavigation?: OnSameUrlNavigation;
replaceUrl?: boolean;
Expand Down
35 changes: 35 additions & 0 deletions packages/router/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
18 changes: 15 additions & 3 deletions packages/router/src/navigation_transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions packages/router/src/statemanager/state_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 &&
Expand All @@ -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;
Expand Down
63 changes: 63 additions & 0 deletions packages/router/test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 1d3a752

Please sign in to comment.