Files
angular__angular/packages/router/src/navigation_transition.ts
Andrew Scott 8735af08b9 feat(router): Add ability to return UrlTree with NavigationBehaviorOptions from guards (#45023)
Returning `UrlTree` from a guard was a convenient new feature added to
the `Router`. However, it does not have feature-parity with the old
`router.navigate(...); return false;` pattern. The most common use-case
for this feature is to redirect to a new page _without_ updating the URL
from the initially attempted navigation. For example, rendering a 404
page when the user does not have access privelages to a route.

Fixes #17004
Fixes #27148

BREAKING CHANGE: Guards can now return `RedirectCommand` for redirects
in addition to `UrlTree`. Code which expects only `boolean` or `UrlTree`
values in `Route` types will need to be adjusted.

PR Close #45023
2024-03-12 09:19:14 -07:00

922 lines
34 KiB
TypeScript

/**
* @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.io/license
*/
import {Location} from '@angular/common';
import {EnvironmentInjector, inject, Injectable, Type} from '@angular/core';
import {BehaviorSubject, combineLatest, EMPTY, from, Observable, of, Subject} from 'rxjs';
import {
catchError,
defaultIfEmpty,
filter,
finalize,
map,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import {createRouterState} from './create_router_state';
import {INPUT_BINDER} from './directives/router_outlet';
import {
BeforeActivateRoutes,
Event,
GuardsCheckEnd,
GuardsCheckStart,
IMPERATIVE_NAVIGATION,
NavigationCancel,
NavigationCancellationCode,
NavigationEnd,
NavigationError,
NavigationSkipped,
NavigationSkippedCode,
NavigationStart,
NavigationTrigger,
RedirectRequest,
ResolveEnd,
ResolveStart,
RouteConfigLoadEnd,
RouteConfigLoadStart,
RoutesRecognized,
} from './events';
import {GuardResult, NavigationBehaviorOptions, QueryParamsHandling, Route, Routes} from './models';
import {
isNavigationCancelingError,
isRedirectingNavigationCancelingError,
redirectingNavigationError,
} from './navigation_canceling_error';
import {activateRoutes} from './operators/activate_routes';
import {checkGuards} from './operators/check_guards';
import {recognize} from './operators/recognize';
import {resolveData} from './operators/resolve_data';
import {switchTap} from './operators/switch_tap';
import {TitleStrategy} from './page_title_strategy';
import {RouteReuseStrategy} from './route_reuse_strategy';
import {ROUTER_CONFIGURATION} from './router_config';
import {RouterConfigLoader} from './router_config_loader';
import {ChildrenOutletContexts} from './router_outlet_context';
import {
ActivatedRoute,
ActivatedRouteSnapshot,
createEmptyState,
RouterState,
RouterStateSnapshot,
} from './router_state';
import {Params} from './shared';
import {UrlHandlingStrategy} from './url_handling_strategy';
import {isUrlTree, UrlSerializer, UrlTree} from './url_tree';
import {Checks, getAllRouteGuards} from './utils/preactivation';
import {CREATE_VIEW_TRANSITION} from './utils/view_transition';
/**
* @description
*
* Options that modify the `Router` URL.
* Supply an object containing any of these properties to a `Router` navigation function to
* control how the target URL should be constructed.
*
* @see {@link Router#navigate}
* @see {@link Router#createUrlTree}
* @see [Routing and Navigation guide](guide/router)
*
* @publicApi
*/
export interface UrlCreationOptions {
/**
* Specifies a root URI to use for relative navigation.
*
* For example, consider the following route configuration where the parent route
* has two children.
*
* ```
* [{
* path: 'parent',
* component: ParentComponent,
* children: [{
* path: 'list',
* component: ListComponent
* },{
* path: 'child',
* component: ChildComponent
* }]
* }]
* ```
*
* The following `go()` function navigates to the `list` route by
* interpreting the destination URI as relative to the activated `child` route
*
* ```
* @Component({...})
* class ChildComponent {
* constructor(private router: Router, private route: ActivatedRoute) {}
*
* go() {
* router.navigate(['../list'], { relativeTo: this.route });
* }
* }
* ```
*
* A value of `null` or `undefined` indicates that the navigation commands should be applied
* relative to the root.
*/
relativeTo?: ActivatedRoute | null;
/**
* Sets query parameters to the URL.
*
* ```
* // Navigate to /results?page=1
* router.navigate(['/results'], { queryParams: { page: 1 } });
* ```
*/
queryParams?: Params | null;
/**
* Sets the hash fragment for the URL.
*
* ```
* // Navigate to /results#top
* router.navigate(['/results'], { fragment: 'top' });
* ```
*/
fragment?: string;
/**
* How to handle query parameters in the router link for the next navigation.
* One of:
* * `preserve` : Preserve current parameters.
* * `merge` : Merge new with current parameters.
*
* The "preserve" option discards any new query params:
* ```
* // from /view1?page=1 to/view2?page=1
* router.navigate(['/view2'], { queryParams: { page: 2 }, queryParamsHandling: "preserve"
* });
* ```
* The "merge" option appends new query params to the params from the current URL:
* ```
* // from /view1?page=1 to/view2?page=1&otherKey=2
* router.navigate(['/view2'], { queryParams: { otherKey: 2 }, queryParamsHandling: "merge"
* });
* ```
* In case of a key collision between current parameters and those in the `queryParams` object,
* the new value is used.
*
*/
queryParamsHandling?: QueryParamsHandling | null;
/**
* When true, preserves the URL fragment for the next navigation
*
* ```
* // Preserve fragment from /results#top to /view#top
* router.navigate(['/view'], { preserveFragment: true });
* ```
*/
preserveFragment?: boolean;
}
/**
* @description
*
* Options that modify the `Router` navigation strategy.
* Supply an object containing any of these properties to a `Router` navigation function to
* control how the target URL should be constructed or interpreted.
*
* @see {@link Router#navigate}
* @see {@link Router#navigateByUrl}
* @see {@link Router#createurltree}
* @see [Routing and Navigation guide](guide/router)
* @see {@link UrlCreationOptions}
* @see {@link NavigationBehaviorOptions}
*
* @publicApi
*/
export interface NavigationExtras extends UrlCreationOptions, NavigationBehaviorOptions {}
export type RestoredState = {
[k: string]: any;
// TODO(#27607): Remove `navigationId` and `ɵrouterPageId` and move to `ng` or `ɵ` namespace.
navigationId: number;
// The `ɵ` prefix is there to reduce the chance of colliding with any existing user properties on
// the history state.
ɵrouterPageId?: number;
};
/**
* Information about a navigation operation.
* Retrieve the most recent navigation object with the
* [Router.getCurrentNavigation() method](api/router/Router#getcurrentnavigation) .
*
* * *id* : The unique identifier of the current navigation.
* * *initialUrl* : The target URL passed into the `Router#navigateByUrl()` call before navigation.
* This is the value before the router has parsed or applied redirects to it.
* * *extractedUrl* : The initial target URL after being parsed with `UrlSerializer.extract()`.
* * *finalUrl* : The extracted URL after redirects have been applied.
* This URL may not be available immediately, therefore this property can be `undefined`.
* It is guaranteed to be set after the `RoutesRecognized` event fires.
* * *trigger* : Identifies how this navigation was triggered.
* -- 'imperative'--Triggered by `router.navigateByUrl` or `router.navigate`.
* -- 'popstate'--Triggered by a popstate event.
* -- 'hashchange'--Triggered by a hashchange event.
* * *extras* : A `NavigationExtras` options object that controlled the strategy used for this
* navigation.
* * *previousNavigation* : The previously successful `Navigation` object. Only one previous
* navigation is available, therefore this previous `Navigation` object has a `null` value for its
* own `previousNavigation`.
*
* @publicApi
*/
export interface Navigation {
/**
* The unique identifier of the current navigation.
*/
id: number;
/**
* The target URL passed into the `Router#navigateByUrl()` call before navigation. This is
* the value before the router has parsed or applied redirects to it.
*/
initialUrl: UrlTree;
/**
* The initial target URL after being parsed with `UrlHandlingStrategy.extract()`.
*/
extractedUrl: UrlTree;
/**
* The extracted URL after redirects have been applied.
* This URL may not be available immediately, therefore this property can be `undefined`.
* It is guaranteed to be set after the `RoutesRecognized` event fires.
*/
finalUrl?: UrlTree;
/**
* 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.
* @internal
*/
targetRouterState?: RouterState;
/**
* Identifies how this navigation was triggered.
*
* * 'imperative'--Triggered by `router.navigateByUrl` or `router.navigate`.
* * 'popstate'--Triggered by a popstate event.
* * 'hashchange'--Triggered by a hashchange event.
*/
trigger: 'imperative' | 'popstate' | 'hashchange';
/**
* Options that controlled the strategy used for this navigation.
* See `NavigationExtras`.
*/
extras: NavigationExtras;
/**
* The previously successful `Navigation` object. Only one previous navigation
* is available, therefore this previous `Navigation` object has a `null` value
* for its own `previousNavigation`.
*/
previousNavigation: Navigation | null;
}
export interface NavigationTransition {
id: number;
currentUrlTree: UrlTree;
extractedUrl: UrlTree;
currentRawUrl: UrlTree;
urlAfterRedirects?: UrlTree;
rawUrl: UrlTree;
extras: NavigationExtras;
resolve: any;
reject: any;
promise: Promise<boolean>;
source: NavigationTrigger;
restoredState: RestoredState | null;
currentSnapshot: RouterStateSnapshot;
targetSnapshot: RouterStateSnapshot | null;
currentRouterState: RouterState;
targetRouterState: RouterState | null;
guards: Checks;
guardsResult: GuardResult | null;
}
/**
* The interface from the Router needed by the transitions. Used to avoid a circular dependency on
* Router. This interface should be whittled down with future refactors. For example, we do not need
* to get `UrlSerializer` from the Router. We can instead inject it in `NavigationTransitions`
* directly.
*/
interface InternalRouterInterface {
config: Routes;
// All of these are public API of router interface and can change during runtime because they are
// writeable. Ideally, these would be removed and the values retrieved instead from the values
// available in DI.
errorHandler: (error: any) => any;
navigated: boolean;
routeReuseStrategy: RouteReuseStrategy;
onSameUrlNavigation: 'reload' | 'ignore';
}
@Injectable({providedIn: 'root'})
export class NavigationTransitions {
currentNavigation: Navigation | null = null;
currentTransition: NavigationTransition | null = null;
lastSuccessfulNavigation: Navigation | null = null;
/**
* These events are used to communicate back to the Router about the state of the transition. The
* Router wants to respond to these events in various ways. Because the `NavigationTransition`
* class is not public, this event subject is not publicly exposed.
*/
readonly events = new Subject<Event | BeforeActivateRoutes | RedirectRequest>();
/**
* Used to abort the current transition with an error.
*/
readonly transitionAbortSubject = new Subject<Error>();
private readonly configLoader = inject(RouterConfigLoader);
private readonly environmentInjector = inject(EnvironmentInjector);
private readonly urlSerializer = inject(UrlSerializer);
private readonly rootContexts = inject(ChildrenOutletContexts);
private readonly location = inject(Location);
private readonly inputBindingEnabled = inject(INPUT_BINDER, {optional: true}) !== null;
private readonly titleStrategy?: TitleStrategy = inject(TitleStrategy);
private readonly options = inject(ROUTER_CONFIGURATION, {optional: true}) || {};
private readonly paramsInheritanceStrategy =
this.options.paramsInheritanceStrategy || 'emptyOnly';
private readonly urlHandlingStrategy = inject(UrlHandlingStrategy);
private readonly createViewTransition = inject(CREATE_VIEW_TRANSITION, {optional: true});
navigationId = 0;
get hasRequestedNavigation() {
return this.navigationId !== 0;
}
private transitions?: BehaviorSubject<NavigationTransition>;
/**
* Hook that enables you to pause navigation after the preactivation phase.
* Used by `RouterModule`.
*
* @internal
*/
afterPreactivation: () => Observable<void> = () => of(void 0);
/** @internal */
rootComponentType: Type<any> | null = null;
constructor() {
const onLoadStart = (r: Route) => this.events.next(new RouteConfigLoadStart(r));
const onLoadEnd = (r: Route) => this.events.next(new RouteConfigLoadEnd(r));
this.configLoader.onLoadEndListener = onLoadEnd;
this.configLoader.onLoadStartListener = onLoadStart;
}
complete() {
this.transitions?.complete();
}
handleNavigationRequest(
request: Pick<
NavigationTransition,
| 'source'
| 'restoredState'
| 'currentUrlTree'
| 'currentRawUrl'
| 'rawUrl'
| 'extras'
| 'resolve'
| 'reject'
| 'promise'
| 'currentSnapshot'
| 'currentRouterState'
>,
) {
const id = ++this.navigationId;
this.transitions?.next({...this.transitions.value, ...request, id});
}
setupNavigations(
router: InternalRouterInterface,
initialUrlTree: UrlTree,
initialRouterState: RouterState,
): Observable<NavigationTransition> {
this.transitions = new BehaviorSubject<NavigationTransition>({
id: 0,
currentUrlTree: initialUrlTree,
currentRawUrl: initialUrlTree,
extractedUrl: this.urlHandlingStrategy.extract(initialUrlTree),
urlAfterRedirects: this.urlHandlingStrategy.extract(initialUrlTree),
rawUrl: initialUrlTree,
extras: {},
resolve: null,
reject: null,
promise: Promise.resolve(true),
source: IMPERATIVE_NAVIGATION,
restoredState: null,
currentSnapshot: initialRouterState.snapshot,
targetSnapshot: null,
currentRouterState: initialRouterState,
targetRouterState: null,
guards: {canActivateChecks: [], canDeactivateChecks: []},
guardsResult: null,
});
return this.transitions.pipe(
filter((t) => t.id !== 0),
// Extract URL
map(
(t) =>
({
...t,
extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl),
}) as NavigationTransition,
),
// Using switchMap so we cancel executing navigations when a new one comes in
switchMap((overallTransitionState) => {
let completed = false;
let errored = false;
return of(overallTransitionState).pipe(
switchMap((t) => {
// It is possible that `switchMap` fails to cancel previous navigations if a new one happens synchronously while the operator
// is processing the `next` notification of that previous navigation. This can happen when a new navigation (say 2) cancels a
// previous one (1) and yet another navigation (3) happens synchronously in response to the `NavigationCancel` event for (1).
// https://github.com/ReactiveX/rxjs/issues/7455
if (this.navigationId > overallTransitionState.id) {
const cancellationReason =
typeof ngDevMode === 'undefined' || ngDevMode
? `Navigation ID ${overallTransitionState.id} is not equal to the current navigation id ${this.navigationId}`
: '';
this.cancelNavigationTransition(
overallTransitionState,
cancellationReason,
NavigationCancellationCode.SupersededByNewNavigation,
);
return EMPTY;
}
this.currentTransition = overallTransitionState;
// Store the Navigation object
this.currentNavigation = {
id: t.id,
initialUrl: t.rawUrl,
extractedUrl: t.extractedUrl,
trigger: t.source,
extras: t.extras,
previousNavigation: !this.lastSuccessfulNavigation
? null
: {
...this.lastSuccessfulNavigation,
previousNavigation: null,
},
};
const urlTransition =
!router.navigated || this.isUpdatingInternalState() || this.isUpdatedBrowserUrl();
const onSameUrlNavigation = t.extras.onSameUrlNavigation ?? router.onSameUrlNavigation;
if (!urlTransition && onSameUrlNavigation !== 'reload') {
const reason =
typeof ngDevMode === 'undefined' || ngDevMode
? `Navigation to ${t.rawUrl} was ignored because it is the same as the current Router URL.`
: '';
this.events.next(
new NavigationSkipped(
t.id,
this.urlSerializer.serialize(t.rawUrl),
reason,
NavigationSkippedCode.IgnoredSameUrlNavigation,
),
);
t.resolve(null);
return EMPTY;
}
if (this.urlHandlingStrategy.shouldProcessUrl(t.rawUrl)) {
return of(t).pipe(
// Fire NavigationStart event
switchMap((t) => {
const transition = this.transitions?.getValue();
this.events.next(
new NavigationStart(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
t.source,
t.restoredState,
),
);
if (transition !== this.transitions?.getValue()) {
return EMPTY;
}
// This delay is required to match old behavior that forced
// navigation to always be async
return Promise.resolve(t);
}),
// Recognize
recognize(
this.environmentInjector,
this.configLoader,
this.rootComponentType,
router.config,
this.urlSerializer,
this.paramsInheritanceStrategy,
),
// Update URL if in `eager` update mode
tap((t) => {
overallTransitionState.targetSnapshot = t.targetSnapshot;
overallTransitionState.urlAfterRedirects = t.urlAfterRedirects;
this.currentNavigation = {
...this.currentNavigation!,
finalUrl: t.urlAfterRedirects,
};
// Fire RoutesRecognized
const routesRecognized = new RoutesRecognized(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
t.targetSnapshot!,
);
this.events.next(routesRecognized);
}),
);
} else if (
urlTransition &&
this.urlHandlingStrategy.shouldProcessUrl(t.currentRawUrl)
) {
/* When the current URL shouldn't be processed, but the previous one
* was, we handle this "error condition" by navigating to the
* previously successful URL, but leaving the URL intact.*/
const {id, extractedUrl, source, restoredState, extras} = t;
const navStart = new NavigationStart(
id,
this.urlSerializer.serialize(extractedUrl),
source,
restoredState,
);
this.events.next(navStart);
const targetSnapshot = createEmptyState(this.rootComponentType).snapshot;
this.currentTransition = overallTransitionState = {
...t,
targetSnapshot,
urlAfterRedirects: extractedUrl,
extras: {...extras, skipLocationChange: false, replaceUrl: false},
};
this.currentNavigation!.finalUrl = extractedUrl;
return of(overallTransitionState);
} else {
/* When neither the current or previous URL can be processed, do
* nothing other than update router's internal reference to the
* current "settled" URL. This way the next navigation will be coming
* from the current URL in the browser.
*/
const reason =
typeof ngDevMode === 'undefined' || ngDevMode
? `Navigation was ignored because the UrlHandlingStrategy` +
` indicated neither the current URL ${t.currentRawUrl} nor target URL ${t.rawUrl} should be processed.`
: '';
this.events.next(
new NavigationSkipped(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
reason,
NavigationSkippedCode.IgnoredByUrlHandlingStrategy,
),
);
t.resolve(null);
return EMPTY;
}
}),
// --- GUARDS ---
tap((t) => {
const guardsStart = new GuardsCheckStart(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
t.targetSnapshot!,
);
this.events.next(guardsStart);
}),
map((t) => {
this.currentTransition = overallTransitionState = {
...t,
guards: getAllRouteGuards(t.targetSnapshot!, t.currentSnapshot, this.rootContexts),
};
return overallTransitionState;
}),
checkGuards(this.environmentInjector, (evt: Event) => this.events.next(evt)),
tap((t) => {
overallTransitionState.guardsResult = t.guardsResult;
if (t.guardsResult && typeof t.guardsResult !== 'boolean') {
throw redirectingNavigationError(this.urlSerializer, t.guardsResult);
}
const guardsEnd = new GuardsCheckEnd(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
t.targetSnapshot!,
!!t.guardsResult,
);
this.events.next(guardsEnd);
}),
filter((t) => {
if (!t.guardsResult) {
this.cancelNavigationTransition(t, '', NavigationCancellationCode.GuardRejected);
return false;
}
return true;
}),
// --- RESOLVE ---
switchTap((t) => {
if (t.guards.canActivateChecks.length) {
return of(t).pipe(
tap((t) => {
const resolveStart = new ResolveStart(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
t.targetSnapshot!,
);
this.events.next(resolveStart);
}),
switchMap((t) => {
let dataResolved = false;
return of(t).pipe(
resolveData(this.paramsInheritanceStrategy, this.environmentInjector),
tap({
next: () => (dataResolved = true),
complete: () => {
if (!dataResolved) {
this.cancelNavigationTransition(
t,
typeof ngDevMode === 'undefined' || ngDevMode
? `At least one route resolver didn't emit any value.`
: '',
NavigationCancellationCode.NoDataFromResolver,
);
}
},
}),
);
}),
tap((t) => {
const resolveEnd = new ResolveEnd(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
t.targetSnapshot!,
);
this.events.next(resolveEnd);
}),
);
}
return undefined;
}),
// --- LOAD COMPONENTS ---
switchTap((t: NavigationTransition) => {
const loadComponents = (route: ActivatedRouteSnapshot): Array<Observable<void>> => {
const loaders: Array<Observable<void>> = [];
if (route.routeConfig?.loadComponent && !route.routeConfig._loadedComponent) {
loaders.push(
this.configLoader.loadComponent(route.routeConfig).pipe(
tap((loadedComponent) => {
route.component = loadedComponent;
}),
map(() => void 0),
),
);
}
for (const child of route.children) {
loaders.push(...loadComponents(child));
}
return loaders;
};
return combineLatest(loadComponents(t.targetSnapshot!.root)).pipe(
defaultIfEmpty(null),
take(1),
);
}),
switchTap(() => this.afterPreactivation()),
switchMap(() => {
const {currentSnapshot, targetSnapshot} = overallTransitionState;
const viewTransitionStarted = this.createViewTransition?.(
this.environmentInjector,
currentSnapshot.root,
targetSnapshot!.root,
);
// If view transitions are enabled, block the navigation until the view
// transition callback starts. Otherwise, continue immediately.
return viewTransitionStarted
? from(viewTransitionStarted).pipe(map(() => overallTransitionState))
: of(overallTransitionState);
}),
map((t: NavigationTransition) => {
const targetRouterState = createRouterState(
router.routeReuseStrategy,
t.targetSnapshot!,
t.currentRouterState,
);
this.currentTransition = overallTransitionState = {...t, targetRouterState};
this.currentNavigation!.targetRouterState = targetRouterState;
return overallTransitionState;
}),
tap(() => {
this.events.next(new BeforeActivateRoutes());
}),
activateRoutes(
this.rootContexts,
router.routeReuseStrategy,
(evt: Event) => this.events.next(evt),
this.inputBindingEnabled,
),
// Ensure that if some observable used to drive the transition doesn't
// complete, the navigation still finalizes This should never happen, but
// this is done as a safety measure to avoid surfacing this error (#49567).
take(1),
tap({
next: (t: NavigationTransition) => {
completed = true;
this.lastSuccessfulNavigation = this.currentNavigation;
this.events.next(
new NavigationEnd(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
this.urlSerializer.serialize(t.urlAfterRedirects!),
),
);
this.titleStrategy?.updateTitle(t.targetRouterState!.snapshot);
t.resolve(true);
},
complete: () => {
completed = true;
},
}),
// There used to be a lot more logic happening directly within the
// transition Observable. Some of this logic has been refactored out to
// other places but there may still be errors that happen there. This gives
// us a way to cancel the transition from the outside. This may also be
// required in the future to support something like the abort signal of the
// Navigation API where the navigation gets aborted from outside the
// transition.
takeUntil(
this.transitionAbortSubject.pipe(
tap((err) => {
throw err;
}),
),
),
finalize(() => {
/* When the navigation stream finishes either through error or success,
* we set the `completed` or `errored` flag. However, there are some
* situations where we could get here without either of those being set.
* For instance, a redirect during NavigationStart. Therefore, this is a
* catch-all to make sure the NavigationCancel event is fired when a
* navigation gets cancelled but not caught by other means. */
if (!completed && !errored) {
const cancelationReason =
typeof ngDevMode === 'undefined' || ngDevMode
? `Navigation ID ${overallTransitionState.id} is not equal to the current navigation id ${this.navigationId}`
: '';
this.cancelNavigationTransition(
overallTransitionState,
cancelationReason,
NavigationCancellationCode.SupersededByNewNavigation,
);
}
// Only clear current navigation if it is still set to the one that
// finalized.
if (this.currentTransition?.id === overallTransitionState.id) {
this.currentNavigation = null;
this.currentTransition = null;
}
}),
catchError((e) => {
errored = true;
/* This error type is issued during Redirect, and is handled as a
* cancellation rather than an error. */
if (isNavigationCancelingError(e)) {
this.events.next(
new NavigationCancel(
overallTransitionState.id,
this.urlSerializer.serialize(overallTransitionState.extractedUrl),
e.message,
e.cancellationCode,
),
);
// When redirecting, we need to delay resolving the navigation
// promise and push it to the redirect navigation
if (!isRedirectingNavigationCancelingError(e)) {
overallTransitionState.resolve(false);
} else {
this.events.next(new RedirectRequest(e.url, e.navigationBehaviorOptions));
}
/* All other errors should reset to the router's internal URL reference
* to the pre-error state. */
} else {
this.events.next(
new NavigationError(
overallTransitionState.id,
this.urlSerializer.serialize(overallTransitionState.extractedUrl),
e,
overallTransitionState.targetSnapshot ?? undefined,
),
);
try {
overallTransitionState.resolve(router.errorHandler(e));
} catch (ee) {
// TODO(atscott): consider flipping the default behavior of
// resolveNavigationPromiseOnError to be `resolve(false)` when
// undefined. This is the most sane thing to do given that
// applications very rarely handle the promise rejection and, as a
// result, would get "unhandled promise rejection" console logs.
// The vast majority of applications would not be affected by this
// change so omitting a migration seems reasonable. Instead,
// applications that rely on rejection can specifically opt-in to the
// old behavior.
if (this.options.resolveNavigationPromiseOnError) {
overallTransitionState.resolve(false);
} else {
overallTransitionState.reject(ee);
}
}
}
return EMPTY;
}),
);
// casting because `pipe` returns observable({}) when called with 8+ arguments
}),
) as Observable<NavigationTransition>;
}
private cancelNavigationTransition(
t: NavigationTransition,
reason: string,
code: NavigationCancellationCode,
) {
const navCancel = new NavigationCancel(
t.id,
this.urlSerializer.serialize(t.extractedUrl),
reason,
code,
);
this.events.next(navCancel);
t.resolve(false);
}
/**
* @returns Whether we're navigating to somewhere that is not what the Router is
* currently set to.
*/
private isUpdatingInternalState() {
// TODO(atscott): The serializer should likely be used instead of
// `UrlTree.toString()`. Custom serializers are often written to handle
// things better than the default one (objects, for example will be
// [Object object] with the custom serializer and be "the same" when they
// aren't).
// (Same for isUpdatedBrowserUrl)
return (
this.currentTransition?.extractedUrl.toString() !==
this.currentTransition?.currentUrlTree.toString()
);
}
/**
* @returns Whether we're updating the browser URL to something new (navigation is going
* to somewhere not displayed in the URL bar and we will update the URL
* bar if navigation succeeds).
*/
private isUpdatedBrowserUrl() {
// 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(
this.urlSerializer.parse(this.location.path(true)),
);
return (
extractedBrowserUrl.toString() !== this.currentTransition?.extractedUrl.toString() &&
!this.currentTransition?.extras.skipLocationChange
);
}
}
export function isBrowserTriggeredNavigation(source: NavigationTrigger) {
return source !== IMPERATIVE_NAVIGATION;
}