refactor(router): move routing into a single Observable stream (#25740)

This is a major refactor of how the router previously worked. There are a couple major advantages of this refactor, and future work will be built on top of it.

First, we will no longer have multiple navigations running at the same time. Previously, a new navigation wouldn't cause the old navigation to be cancelled and cleaned up. Instead, multiple navigations could be going at once, and we imperatively checked that we were operating on the most current `router.navigationId` as we progressed through the Observable streams. This had some major faults, the biggest of which was async races where an ongoing async action could result in a redirect once the async action completed, but there was no way to guarantee there weren't also other redirects that would be queued up by other async actions. After this refactor, there's a single Observable stream that will get cleaned up each time a new navigation is requested.

Additionally, the individual pieces of routing have been pulled out into their own operators. While this was needed in order to create one continuous stream, it also will allow future improvements to the testing APIs as things such as Guards or Resolvers should now be able to be tested in much more isolation.

* Add the new `router.transitions` observable of the new `NavigationTransition` type to contain the transition information
* Update `router.navigations` to pipe off of `router.transitions`
* Re-write navigation Observable flow to a single configured stream
* Refactor `switchMap` instead of the previous `mergeMap` to ensure new navigations cause a cancellation and cleanup of already running navigations
* Wire in existing error and cancellation logic so cancellation matches previous behavior

PR Close #25740
This commit is contained in:
Jason Aden 2018-08-15 15:27:18 -07:00 committed by Kara Erickson
parent 5d689469f6
commit 4bb10d224c
12 changed files with 415 additions and 364 deletions

View File

@ -14,7 +14,7 @@ export {RouterOutlet} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events'; export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces';
export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {NavigationExtras, Router} from './router'; export {NavigationExtras, NavigationTransition, Router} from './router';
export {ROUTES} from './router_config_loader'; export {ROUTES} from './router_config_loader';
export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module'; export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module';
export {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; export {ChildrenOutletContexts, OutletContext} from './router_outlet_context';

View File

@ -9,21 +9,17 @@
import {MonoTypeOperatorFunction} from 'rxjs'; import {MonoTypeOperatorFunction} from 'rxjs';
import {map, mergeMap} from 'rxjs/operators'; import {map, mergeMap} from 'rxjs/operators';
import {RouterHook} from '../router'; import {NavigationTransition, RouterHook} from '../router';
import {RouterStateSnapshot} from '../router_state';
import {UrlTree} from '../url_tree';
export function afterPreactivation( export function afterPreactivation(hook: RouterHook):
hook: RouterHook, navigationId: number, appliedUrlTree: UrlTree, rawUrlTree: UrlTree, MonoTypeOperatorFunction<NavigationTransition> {
skipLocationChange: boolean, replaceUrl: boolean):
MonoTypeOperatorFunction<{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}> {
return function(source) { return function(source) {
return source.pipe(mergeMap( return source.pipe(mergeMap(t => hook(t.targetSnapshot !, {
p => hook( navigationId: t.id,
p.snapshot, appliedUrlTree: t.extractedUrl,
{ rawUrlTree: t.rawUrl,
navigationId, appliedUrlTree, rawUrlTree, skipLocationChange, replaceUrl, skipLocationChange: !!t.extras.skipLocationChange,
}) replaceUrl: !!t.extras.replaceUrl,
.pipe(map(() => p)))); }).pipe(map(() => t))));
}; };
} }

View File

@ -7,25 +7,21 @@
*/ */
import {Injector} from '@angular/core'; import {Injector} from '@angular/core';
import {Observable, OperatorFunction} from 'rxjs'; import {MonoTypeOperatorFunction, Observable} from 'rxjs';
import {flatMap} from 'rxjs/operators'; import {flatMap, map} from 'rxjs/operators';
import {applyRedirects as applyRedirectsFn} from '../apply_redirects'; import {applyRedirects as applyRedirectsFn} from '../apply_redirects';
import {Routes} from '../config'; import {Routes} from '../config';
import {NavigationTransition} from '../router';
import {RouterConfigLoader} from '../router_config_loader'; import {RouterConfigLoader} from '../router_config_loader';
import {UrlSerializer, UrlTree} from '../url_tree'; import {UrlSerializer} from '../url_tree';
/**
* Returns the `UrlTree` with the redirection applied.
*
* Lazy modules are loaded along the way.
*/
export function applyRedirects( export function applyRedirects(
moduleInjector: Injector, configLoader: RouterConfigLoader, urlSerializer: UrlSerializer, moduleInjector: Injector, configLoader: RouterConfigLoader, urlSerializer: UrlSerializer,
config: Routes): OperatorFunction<UrlTree, UrlTree> { config: Routes): MonoTypeOperatorFunction<NavigationTransition> {
return function(source: Observable<UrlTree>) { return function(source: Observable<NavigationTransition>) {
return source.pipe(flatMap( return source.pipe(flatMap(
urlTree => applyRedirectsFn(moduleInjector, configLoader, urlSerializer, urlTree, config))); t => applyRedirectsFn(moduleInjector, configLoader, urlSerializer, t.extractedUrl, config)
.pipe(map(url => ({...t, urlAfterRedirects: url})))));
}; };
} }

View File

@ -9,21 +9,17 @@
import {MonoTypeOperatorFunction} from 'rxjs'; import {MonoTypeOperatorFunction} from 'rxjs';
import {map, mergeMap} from 'rxjs/operators'; import {map, mergeMap} from 'rxjs/operators';
import {RouterHook} from '../router'; import {NavigationTransition, RouterHook} from '../router';
import {RouterStateSnapshot} from '../router_state';
import {UrlTree} from '../url_tree';
export function beforePreactivation( export function beforePreactivation(hook: RouterHook):
hook: RouterHook, navigationId: number, appliedUrlTree: UrlTree, rawUrlTree: UrlTree, MonoTypeOperatorFunction<NavigationTransition> {
skipLocationChange: boolean, replaceUrl: boolean):
MonoTypeOperatorFunction<{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}> {
return function(source) { return function(source) {
return source.pipe(mergeMap( return source.pipe(mergeMap(t => hook(t.targetSnapshot !, {
p => hook( navigationId: t.id,
p.snapshot, appliedUrlTree: t.extractedUrl,
{ rawUrlTree: t.rawUrl,
navigationId, appliedUrlTree, rawUrlTree, skipLocationChange, replaceUrl, skipLocationChange: !!t.extras.skipLocationChange,
}) replaceUrl: !!t.extras.replaceUrl,
.pipe(map(() => p)))); }).pipe(map(() => t))));
}; };
} }

View File

@ -6,22 +6,19 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {Injector, Type} from '@angular/core'; import {MonoTypeOperatorFunction, Observable, from, of } from 'rxjs';
import {Observable, OperatorFunction} from 'rxjs'; import {map, mergeMap} from 'rxjs/operators';
import {mergeMap} from 'rxjs/operators';
import {Route} from '../config'; import {NavigationTransition} from '../router';
import {PreActivation} from '../pre_activation';
import {recognize as recognizeFn} from '../recognize';
import {ChildrenOutletContexts} from '../router_outlet_context';
import {RouterStateSnapshot} from '../router_state';
import {UrlTree} from '../url_tree';
export function checkGuards( export function checkGuards(): MonoTypeOperatorFunction<NavigationTransition> {
rootContexts: ChildrenOutletContexts, currentSnapshot: RouterStateSnapshot, return function(source: Observable<NavigationTransition>) {
moduleInjector: Injector, preActivation: PreActivation): OperatorFunction<UrlTree, boolean> {
return function(source: Observable<UrlTree>) { return source.pipe(mergeMap(t => {
return source.pipe( if (!t.preActivation) {
mergeMap((appliedUrl): Observable<boolean> => { return preActivation.checkGuards(); })); throw 'Initialized PreActivation required to check guards';
}
return t.preActivation.checkGuards().pipe(map(guardsResult => ({...t, guardsResult})));
}));
}; };
} }

View File

@ -0,0 +1,23 @@
/**
* @license
* Copyright Google Inc. 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 {EMPTY, MonoTypeOperatorFunction, Observable, of } from 'rxjs';
import {mergeMap} from 'rxjs/operators';
export function mergeMapIf<T>(
predicate: (value: T) => boolean, tap: (value: T) => any): MonoTypeOperatorFunction<T> {
return (source: Observable<T>) => {
return source.pipe(mergeMap(s => {
if (predicate(s)) {
tap(s);
return EMPTY;
}
return of (s);
}));
};
}

View File

@ -7,22 +7,23 @@
*/ */
import {Type} from '@angular/core'; import {Type} from '@angular/core';
import {Observable, OperatorFunction} from 'rxjs'; import {MonoTypeOperatorFunction, Observable} from 'rxjs';
import {mergeMap} from 'rxjs/operators'; import {map, mergeMap} from 'rxjs/operators';
import {Route} from '../config'; import {Route} from '../config';
import {recognize as recognizeFn} from '../recognize'; import {recognize as recognizeFn} from '../recognize';
import {RouterStateSnapshot} from '../router_state'; import {NavigationTransition} from '../router';
import {UrlTree} from '../url_tree'; import {UrlTree} from '../url_tree';
export function recognize( export function recognize(
rootComponentType: Type<any>| null, config: Route[], serializer: (url: UrlTree) => string, rootComponentType: Type<any>| null, config: Route[], serializer: (url: UrlTree) => string,
paramsInheritanceStrategy: 'emptyOnly' | paramsInheritanceStrategy: 'emptyOnly' |
'always'): OperatorFunction<UrlTree, RouterStateSnapshot> { 'always'): MonoTypeOperatorFunction<NavigationTransition> {
return function(source: Observable<UrlTree>) { return function(source: Observable<NavigationTransition>) {
return source.pipe(mergeMap( return source.pipe(mergeMap(
(appliedUrl: UrlTree) => recognizeFn( t => recognizeFn(
rootComponentType, config, appliedUrl, serializer(appliedUrl), rootComponentType, config, t.urlAfterRedirects, serializer(t.extractedUrl),
paramsInheritanceStrategy))); paramsInheritanceStrategy)
.pipe(map(targetSnapshot => ({...t, targetSnapshot})))));
}; };
} }

View File

@ -6,23 +6,19 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {Injector, Type} from '@angular/core'; import {MonoTypeOperatorFunction, Observable, OperatorFunction, from, of } from 'rxjs';
import {Observable, OperatorFunction} from 'rxjs'; import {map, mergeMap} from 'rxjs/operators';
import {mergeMap} from 'rxjs/operators';
import {Route} from '../config'; import {NavigationTransition} from '../router';
import {PreActivation} from '../pre_activation';
import {recognize as recognizeFn} from '../recognize';
import {ChildrenOutletContexts} from '../router_outlet_context';
import {RouterStateSnapshot} from '../router_state';
import {UrlTree} from '../url_tree';
export function resolveData( export function resolveData(
preActivation: PreActivation, paramsInheritanceStrategy: 'emptyOnly' | 'always'): MonoTypeOperatorFunction<NavigationTransition> {
paramsInheritanceStrategy: 'emptyOnly' | 'always'): OperatorFunction<UrlTree, boolean> { return function(source: Observable<NavigationTransition>) {
return function(source: Observable<UrlTree>) { return source.pipe(mergeMap(t => {
return source.pipe(mergeMap((appliedUrl): Observable<boolean> => { if (!t.preActivation) {
return preActivation.resolveData(paramsInheritanceStrategy); throw 'Initialized PreActivation required to check guards';
}
return t.preActivation.resolveData(paramsInheritanceStrategy).pipe(map(_ => t));
})); }));
}; };
} }

View File

@ -0,0 +1,26 @@
/**
* @license
* Copyright Google Inc. 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 {MonoTypeOperatorFunction, Observable} from 'rxjs';
import {tap} from 'rxjs/operators';
export function throwIf<T>(
predicate: (value: T) => boolean,
errorFactory: (() => any) = defaultErrorFactory): MonoTypeOperatorFunction<T> {
return (source: Observable<T>) => {
return source.pipe(tap(s => {
if (predicate(s)) {
throw errorFactory();
}
}));
};
}
function defaultErrorFactory() {
return new Error();
}

View File

@ -7,31 +7,32 @@
*/ */
import {Location} from '@angular/common'; import {Location} from '@angular/common';
import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Optional, Type, isDevMode, ɵConsole as Console} from '@angular/core'; import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, isDevMode, ɵConsole as Console} from '@angular/core';
import {BehaviorSubject, Observable, Subject, Subscription, of } from 'rxjs'; import {BehaviorSubject, EMPTY, MonoTypeOperatorFunction, Observable, Subject, Subscription, of } from 'rxjs';
import {concatMap, map, mergeMap, tap} from 'rxjs/operators'; import {catchError, filter, finalize, map, mergeMap, switchMap, tap} from 'rxjs/operators';
import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config'; import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config';
import {createRouterState} from './create_router_state'; import {createRouterState} from './create_router_state';
import {createUrlTree} from './create_url_tree'; import {createUrlTree} from './create_url_tree';
import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
import {afterPreactivation} from './operators/after_preactivation';
import {applyRedirects} from './operators/apply_redirects'; import {applyRedirects} from './operators/apply_redirects';
import {beforePreactivation} from './operators/before_preactivation'; import {beforePreactivation} from './operators/before_preactivation';
import {checkGuards} from './operators/check_guards'; import {checkGuards} from './operators/check_guards';
import {mergeMapIf} from './operators/mergeMapIf';
import {recognize} from './operators/recognize'; import {recognize} from './operators/recognize';
import {resolveData} from './operators/resolve_data'; import {resolveData} from './operators/resolve_data';
import {setupPreactivation} from './operators/setup_preactivation';
import {PreActivation} from './pre_activation'; import {PreActivation} from './pre_activation';
import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy';
import {RouterConfigLoader} from './router_config_loader'; import {RouterConfigLoader} from './router_config_loader';
import {ChildrenOutletContexts} from './router_outlet_context'; import {ChildrenOutletContexts} from './router_outlet_context';
import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState, inheritedParamsDataResolve} from './router_state'; import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState} from './router_state';
import {Params, isNavigationCancelingError} from './shared'; import {Params, isNavigationCancelingError} from './shared';
import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy'; import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy';
import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree'; import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree';
import {assertDefined} from './utils/assert';
import {forEach} from './utils/collection'; import {forEach} from './utils/collection';
import {TreeNode, nodeChildrenAsMap} from './utils/tree'; import {TreeNode, nodeChildrenAsMap} from './utils/tree';
import { afterPreactivation } from './operators/after_preactivation';
@ -174,15 +175,25 @@ function defaultMalformedUriErrorHandler(
type NavStreamValue = type NavStreamValue =
boolean | {appliedUrl: UrlTree, snapshot: RouterStateSnapshot, shouldActivate?: boolean}; boolean | {appliedUrl: UrlTree, snapshot: RouterStateSnapshot, shouldActivate?: boolean};
type NavigationParams = { export type NavigationTransition = {
id: number, id: number,
currentUrlTree: UrlTree,
currentRawUrl: UrlTree,
extractedUrl: UrlTree,
urlAfterRedirects: UrlTree,
rawUrl: UrlTree, rawUrl: UrlTree,
extras: NavigationExtras, extras: NavigationExtras,
resolve: any, resolve: any,
reject: any, reject: any,
promise: Promise<boolean>, promise: Promise<boolean>,
source: NavigationTrigger, source: NavigationTrigger,
state: {navigationId: number} | null state: {navigationId: number} | null,
currentSnapshot: RouterStateSnapshot,
targetSnapshot: RouterStateSnapshot | null,
currentRouterState: RouterState,
targetRouterState: RouterState | null,
guardsResult: boolean | null,
preActivation: PreActivation | null
}; };
/** /**
@ -223,7 +234,7 @@ function defaultRouterHook(snapshot: RouterStateSnapshot, runExtras: {
export class Router { export class Router {
private currentUrlTree: UrlTree; private currentUrlTree: UrlTree;
private rawUrlTree: UrlTree; private rawUrlTree: UrlTree;
private navigations = new BehaviorSubject<NavigationParams>(null !); private navigations: Observable<NavigationTransition>;
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
private locationSubscription !: Subscription; private locationSubscription !: Subscription;
@ -233,6 +244,7 @@ export class Router {
private console: Console; private console: Console;
private isNgZoneEnabled: boolean = false; private isNgZoneEnabled: boolean = false;
public readonly transitions: BehaviorSubject<NavigationTransition>;
public readonly events: Observable<Event> = new Subject<Event>(); public readonly events: Observable<Event> = new Subject<Event>();
public readonly routerState: RouterState; public readonly routerState: RouterState;
@ -332,9 +344,211 @@ export class Router {
this.configLoader = new RouterConfigLoader(loader, compiler, onLoadStart, onLoadEnd); this.configLoader = new RouterConfigLoader(loader, compiler, onLoadStart, onLoadEnd);
this.routerState = createEmptyState(this.currentUrlTree, this.rootComponentType); this.routerState = createEmptyState(this.currentUrlTree, this.rootComponentType);
this.transitions = new BehaviorSubject<NavigationTransition>({
id: 0,
currentUrlTree: this.currentUrlTree,
currentRawUrl: this.currentUrlTree,
extractedUrl: this.urlHandlingStrategy.extract(this.currentUrlTree),
urlAfterRedirects: this.urlHandlingStrategy.extract(this.currentUrlTree),
rawUrl: this.currentUrlTree,
extras: {},
resolve: null,
reject: null,
promise: Promise.resolve(true),
source: 'imperative',
state: null,
currentSnapshot: this.routerState.snapshot,
targetSnapshot: null,
currentRouterState: this.routerState,
targetRouterState: null,
guardsResult: null,
preActivation: null
});
this.navigations = this.setupNavigations(this.transitions);
this.processNavigations(); this.processNavigations();
} }
private setupNavigations(transitions: Observable<NavigationTransition>): Observable<NavigationTransition> {
return 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(t => {
let completed = false;
let errored = false;
return of (t).pipe(
mergeMap(t => {
const urlTransition =
!this.navigated || t.extractedUrl.toString() !== this.currentUrlTree.toString();
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
this.urlHandlingStrategy.shouldProcessUrl(t.rawUrl)) {
return of (t).pipe(
// Update URL if in `eager` update mode
tap(t => this.urlUpdateStrategy === 'eager' && !t.extras.skipLocationChange &&
this.setBrowserUrl(t.rawUrl, !!t.extras.replaceUrl, t.id)),
// Fire NavigationStart event
tap(t =>
(this.events as Subject<Event>)
.next(new NavigationStart(
t.id, this.serializeUrl(t.extractedUrl), t.source, t.state))),
// This delay is required to match old behavior that forced navigation to
// always be async
mergeMap(t => Promise.resolve(t)),
// ApplyRedirects
applyRedirects(
this.ngModule.injector, this.configLoader, this.urlSerializer,
this.config),
// Recognize
recognize(
this.rootComponentType, this.config, (url) => this.serializeUrl(url),
this.paramsInheritanceStrategy),
// Throw if there's no snapshot
tap(t => assertDefined(t.targetSnapshot, 'snapshot must be defined')),
// Fire RoutesRecognized
tap(t => (this.events as Subject<Event>)
.next(new RoutesRecognized(
t.id, this.serializeUrl(t.extractedUrl),
this.serializeUrl(t.urlAfterRedirects),
t.targetSnapshot !))), );
} else if (
urlTransition && this.rawUrlTree &&
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
(this.events as Subject<Event>)
.next(new NavigationStart(
t.id, this.serializeUrl(t.extractedUrl), t.source, t.state));
return of ({
...t,
urlAfterRedirects: t.extractedUrl,
extras: {...t.extras, skipLocationChange: false, replaceUrl: false},
targetSnapshot:
createEmptyState(t.extractedUrl, this.rootComponentType).snapshot
});
} else {
this.rawUrlTree = t.rawUrl;
t.resolve(null);
return EMPTY;
}
}),
// Before Preactivation
beforePreactivation(this.hooks.beforePreactivation),
// --- GUARDS ---
tap(t => this.triggerEvent(new GuardsCheckStart(
t.id, this.serializeUrl(t.extractedUrl),
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))),
map(t => {
const preActivation = new PreActivation(
t.targetSnapshot !, t.currentSnapshot, this.ngModule.injector,
(evt: Event) => this.triggerEvent(evt));
preActivation.initialize(this.rootContexts);
return {...t, preActivation};
}),
checkGuards(),
tap(t => this.triggerEvent(new GuardsCheckEnd(
t.id, this.serializeUrl(t.extractedUrl),
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !,
!!t.guardsResult))),
mergeMapIf(
t => !t.guardsResult,
t => {
this.resetUrlToCurrentUrlTree();
(this.events as Subject<Event>)
.next(new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), ''));
t.resolve(false);
}),
// --- RESOLVE ---
mergeMap(t => {
if (t.preActivation !.isActivating()) {
return of (t).pipe(
tap(t => this.triggerEvent(new ResolveStart(
t.id, this.serializeUrl(t.extractedUrl),
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))),
resolveData(this.paramsInheritanceStrategy),
tap(t => this.triggerEvent(new ResolveEnd(
t.id, this.serializeUrl(t.extractedUrl),
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))), );
}
return of (t);
}),
// --- AFTER PREACTIVATION ---
afterPreactivation(this.hooks.afterPreactivation), map(t => {
const targetRouterState = createRouterState(
this.routeReuseStrategy, t.targetSnapshot !, t.currentRouterState);
return ({...t, targetRouterState});
}),
// Side effects of resetting Router instance
tap(t => {
this.currentUrlTree = t.urlAfterRedirects;
this.rawUrlTree = this.urlHandlingStrategy.merge(this.currentUrlTree, t.rawUrl);
(this as{routerState: RouterState}).routerState = t.targetRouterState !;
if (this.urlUpdateStrategy === 'deferred' && !t.extras.skipLocationChange) {
this.setBrowserUrl(this.rawUrlTree, !!t.extras.replaceUrl, t.id);
}
}),
activate(
this.rootContexts, this.routeReuseStrategy,
(evt: Event) => this.triggerEvent(evt)),
tap({next: () => completed = true, complete: () => completed = true}),
finalize(() => {
if (!completed && !errored) {
(this.events as Subject<Event>)
.next(new NavigationCancel(
t.id, this.serializeUrl(t.extractedUrl),
`Navigation ID ${t.id} is not equal to the current navigation id ${this.navigationId}`));
t.resolve(false);
}
}),
catchError((e) => {
errored = true;
if (isNavigationCancelingError(e)) {
this.navigated = true;
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl);
(this.events as Subject<Event>)
.next(
new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), e.message));
} else {
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl);
(this.events as Subject<Event>)
.next(new NavigationError(t.id, this.serializeUrl(t.extractedUrl), e));
try {
t.resolve(this.errorHandler(e));
} catch (ee) {
t.reject(ee);
}
}
return EMPTY;
}), );
})) as any as Observable<NavigationTransition>;
function activate(
rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy,
forwardEvent: (evt: Event) => void): MonoTypeOperatorFunction<NavigationTransition> {
return function(source: Observable<NavigationTransition>) {
return source.pipe(map(t => {
new ActivateRoutes(
routeReuseStrategy, t.targetRouterState !, t.currentRouterState, forwardEvent)
.activate(rootContexts);
return t;
}));
};
}
}
/** /**
* @internal * @internal
* TODO: this should be removed once the constructor of the router made internal * TODO: this should be removed once the constructor of the router made internal
@ -346,6 +560,12 @@ export class Router {
this.routerState.root.component = this.rootComponentType; this.routerState.root.component = this.rootComponentType;
} }
private getTransition(): NavigationTransition { return this.transitions.value; }
private setTransition(t: Partial<NavigationTransition>): void {
this.transitions.next({...this.getTransition(), ...t});
}
/** /**
* Sets up the location change listener and performs the initial navigation. * Sets up the location change listener and performs the initial navigation.
*/ */
@ -582,24 +802,22 @@ export class Router {
} }
private processNavigations(): void { private processNavigations(): void {
this.navigations this.navigations.subscribe(
.pipe(concatMap((nav: NavigationParams) => { t => {
if (nav) { this.navigated = true;
this.executeScheduledNavigation(nav); this.lastSuccessfulId = t.id;
// a failed navigation should not stop the router from processing (this.events as Subject<Event>)
// further navigations => the catch .next(new NavigationEnd(
return nav.promise.catch(() => {}); t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree)));
} else { t.resolve(true);
return <any>of (null); },
} e => { throw 'never get here!'; });
}))
.subscribe(() => {});
} }
private scheduleNavigation( private scheduleNavigation(
rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null, rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
extras: NavigationExtras): Promise<boolean> { extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.navigations.value; const lastNavigation = this.getTransition();
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
// and that navigation results in 'replaceState' that leads to the same URL, // and that navigation results in 'replaceState' that leads to the same URL,
// we should skip those. // we should skip those.
@ -632,243 +850,19 @@ export class Router {
}); });
const id = ++this.navigationId; const id = ++this.navigationId;
this.navigations.next({id, source, state, rawUrl, extras, resolve, reject, promise}); this.setTransition({
id,
source,
state,
currentUrlTree: this.currentUrlTree,
currentRawUrl: this.rawUrlTree, rawUrl, extras, resolve, reject, promise,
currentSnapshot: this.routerState.snapshot,
currentRouterState: this.routerState
});
// Make sure that the error is propagated even though `processNavigations` catch // Make sure that the error is propagated even though `processNavigations` catch
// handler does not rethrow // handler does not rethrow
return promise.catch((e: any) => Promise.reject(e)); return promise.catch((e: any) => { return Promise.reject(e); });
}
private executeScheduledNavigation({id, rawUrl, extras, resolve, reject, source,
state}: NavigationParams): void {
const url = this.urlHandlingStrategy.extract(rawUrl);
const urlTransition = !this.navigated || url.toString() !== this.currentUrlTree.toString();
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) {
if (this.urlUpdateStrategy === 'eager' && !extras.skipLocationChange) {
this.setBrowserUrl(rawUrl, !!extras.replaceUrl, id);
}
(this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve()
.then(
(_) => this.runNavigate(
url, rawUrl, !!extras.skipLocationChange, !!extras.replaceUrl, id, null))
.then(resolve, reject);
// we cannot process the current URL, but we could process the previous one =>
// we need to do some cleanup
} else if (
urlTransition && this.rawUrlTree &&
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
(this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve()
.then(
(_) => this.runNavigate(
url, rawUrl, false, false, id,
createEmptyState(url, this.rootComponentType).snapshot))
.then(resolve, reject);
} else {
this.rawUrlTree = rawUrl;
resolve(null);
}
}
private runNavigate(
url: UrlTree, rawUrl: UrlTree, skipLocationChange: boolean, replaceUrl: boolean, id: number,
precreatedState: RouterStateSnapshot|null): Promise<boolean> {
if (id !== this.navigationId) {
(this.events as Subject<Event>)
.next(new NavigationCancel(
id, this.serializeUrl(url),
`Navigation ID ${id} is not equal to the current navigation id ${this.navigationId}`));
return Promise.resolve(false);
}
return new Promise((resolvePromise, rejectPromise) => {
// create an observable of the url and route state snapshot
// this operation do not result in any side effects
let urlAndSnapshot$ = of (url).pipe(mergeMap(url => {
if (precreatedState) {
return of ({appliedUrl: url, snapshot: precreatedState});
} else {
return applyRedirects(
this.ngModule.injector, this.configLoader, this.urlSerializer,
this.config)(of (url))
.pipe(mergeMap(
appliedUrl =>
recognize(
this.rootComponentType, this.config, (url) => this.serializeUrl(url),
this.paramsInheritanceStrategy)(of (appliedUrl))
.pipe(
map((snapshot: RouterStateSnapshot) => ({appliedUrl, snapshot})),
tap(({appliedUrl, snapshot}:
{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}) =>
(this.events as Subject<Event>)
.next(new RoutesRecognized(
id, this.serializeUrl(url),
this.serializeUrl(appliedUrl), snapshot))))));
}
}));
const beforePreactivationDone$ = urlAndSnapshot$.pipe(beforePreactivation(
this.hooks.beforePreactivation, id, url, rawUrl, skipLocationChange, replaceUrl));
// run preactivation: guards and data resolvers
let preActivation: PreActivation;
const preactivationSetup$ = beforePreactivationDone$.pipe(
mergeMap(
p => of (p.snapshot)
.pipe(
setupPreactivation(
this.rootContexts, this.routerState.snapshot, this.ngModule.injector,
(evt: Event) => this.triggerEvent(evt)),
map(preActivation => ({...p, preActivation})))),
tap(p => preActivation = p.preActivation));
const preactivationCheckGuards$: Observable<NavStreamValue> =
preactivationSetup$.pipe(mergeMap(
p => this.navigationId !== id ?
of (false) :
of (p.appliedUrl)
.pipe(
tap(_ => this.triggerEvent(new GuardsCheckStart(
id, this.serializeUrl(url), this.serializeUrl(p.appliedUrl),
p.snapshot))),
checkGuards(
this.rootContexts, this.routerState.snapshot, this.ngModule.injector,
preActivation),
tap(shouldActivate => this.triggerEvent(new GuardsCheckEnd(
id, this.serializeUrl(url), this.serializeUrl(p.appliedUrl),
p.snapshot, shouldActivate))),
map(shouldActivate => ({
appliedUrl: p.appliedUrl,
snapshot: p.snapshot,
shouldActivate: shouldActivate
})))));
const preactivationResolveData$: Observable<NavStreamValue> = preactivationCheckGuards$.pipe(mergeMap(p =>
// TODO(jasonaden): This should be simplified so there's one route to cancelling navigation, which would
// unravel the stream. This would get rid of all these imperative checks in the middle of navigation.
typeof p === 'boolean' || this.navigationId !== id ?
of (false) :
p.shouldActivate && preActivation.isActivating() ?
of (p.appliedUrl)
.pipe(
tap(_ => this.triggerEvent(new ResolveStart(
id, this.serializeUrl(url), this.serializeUrl(p.appliedUrl),
p.snapshot))),
resolveData(preActivation, this.paramsInheritanceStrategy),
tap(_ => this.triggerEvent(new ResolveEnd(
id, this.serializeUrl(url), this.serializeUrl(p.appliedUrl),
p.snapshot))),
map(_ => p)) :
of (p)
));
const preactivationDone$: Observable<NavStreamValue> =
preactivationResolveData$.pipe(mergeMap(p =>
typeof p === 'boolean' || this.navigationId !== id ? of (false) :
of (p).pipe(
afterPreactivation(this.hooks.afterPreactivation, id, url, rawUrl, skipLocationChange, replaceUrl),
map(() => p))
));
// create router state
// this operation has side effects => route state is being affected
const routerState$ = preactivationDone$.pipe(map((p) => {
if (typeof p === 'boolean' || this.navigationId !== id) return false;
const {appliedUrl, snapshot, shouldActivate} = p;
if (shouldActivate) {
const state = createRouterState(this.routeReuseStrategy, snapshot, this.routerState);
return {appliedUrl, state, shouldActivate};
} else {
return {appliedUrl, state: null, shouldActivate};
}
}));
this.activateRoutes(
routerState$, this.routerState, this.currentUrlTree, id, url, rawUrl, skipLocationChange,
replaceUrl, resolvePromise, rejectPromise);
});
}
/**
* Performs the logic of activating routes. This is a synchronous process by default. While this
* is a private method, it could be overridden to make activation asynchronous.
*/
private activateRoutes(
state: Observable<false|
{appliedUrl: UrlTree, state: RouterState|null, shouldActivate?: boolean}>,
storedState: RouterState, storedUrl: UrlTree, id: number, url: UrlTree, rawUrl: UrlTree,
skipLocationChange: boolean, replaceUrl: boolean, resolvePromise: any, rejectPromise: any) {
// applied the new router state
// this operation has side effects
let navigationIsSuccessful: boolean;
state
.forEach((p) => {
if (typeof p === 'boolean' || !p.shouldActivate || id !== this.navigationId || !p.state) {
navigationIsSuccessful = false;
return;
}
const {appliedUrl, state} = p;
this.currentUrlTree = appliedUrl;
this.rawUrlTree = this.urlHandlingStrategy.merge(this.currentUrlTree, rawUrl);
(this as{routerState: RouterState}).routerState = state;
if (this.urlUpdateStrategy === 'deferred' && !skipLocationChange) {
this.setBrowserUrl(this.rawUrlTree, replaceUrl, id);
}
new ActivateRoutes(
this.routeReuseStrategy, state, storedState, (evt: Event) => this.triggerEvent(evt))
.activate(this.rootContexts);
navigationIsSuccessful = true;
})
.then(
() => {
if (navigationIsSuccessful) {
this.navigated = true;
this.lastSuccessfulId = id;
(this.events as Subject<Event>)
.next(new NavigationEnd(
id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree)));
resolvePromise(true);
} else {
this.resetUrlToCurrentUrlTree();
(this.events as Subject<Event>)
.next(new NavigationCancel(id, this.serializeUrl(url), ''));
resolvePromise(false);
}
},
(e: any) => {
if (isNavigationCancelingError(e)) {
this.navigated = true;
this.resetStateAndUrl(storedState, storedUrl, rawUrl);
(this.events as Subject<Event>)
.next(new NavigationCancel(id, this.serializeUrl(url), e.message));
resolvePromise(false);
} else {
this.resetStateAndUrl(storedState, storedUrl, rawUrl);
(this.events as Subject<Event>)
.next(new NavigationError(id, this.serializeUrl(url), e));
try {
resolvePromise(this.errorHandler(e));
} catch (ee) {
rejectPromise(ee);
}
}
});
} }
private setBrowserUrl(url: UrlTree, replaceUrl: boolean, id: number) { private setBrowserUrl(url: UrlTree, replaceUrl: boolean, id: number) {

View File

@ -0,0 +1,19 @@
/**
* @license
* Copyright Google Inc. 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
*/
export function assertDefined<T>(actual: T, msg: string) {
if (actual == null) {
throwError(msg);
}
}
function throwError(msg: string): never {
// tslint:disable-next-line
debugger; // Left intentionally for better debugger experience.
throw new Error(`ASSERTION ERROR: ${msg}`);
}

View File

@ -291,41 +291,49 @@ describe('Integration', () => {
}); });
it('should execute navigations serialy', // TODO(jasonaden): This test now fails because it relies on waiting on a guard to finish
fakeAsync(inject([Router, Location], (router: Router) => { // executing even after a new navigation has been scheduled. The previous implementation
const fixture = createRoot(router, RootCmp); // would do so, but ignore the result of any guards that are executing when a new navigation
// is scheduled.
router.resetConfig([ // With new implementation, the current navigation will be unrolled and cleaned up so the
{path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, // new navigation can start immediately. This test therefore fails as it relies on that
{path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} // previous incorrect behavior.
]); xit('should execute navigations serialy',
fakeAsync(inject([Router, Location], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.navigateByUrl('/a'); router.resetConfig([
tick(100); {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']},
fixture.detectChanges(); {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}
]);
router.navigateByUrl('/b'); router.navigateByUrl('/a');
tick(100); // 200 tick(100);
fixture.detectChanges(); fixture.detectChanges();
expect(log).toEqual(['trueRightAway', 'trueIn2Seconds-start']); router.navigateByUrl('/b');
tick(100); // 200
fixture.detectChanges();
tick(2000); // 2200 expect(log).toEqual(['trueRightAway', 'trueIn2Seconds-start']);
fixture.detectChanges();
expect(log).toEqual([ tick(2000); // 2200
'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', fixture.detectChanges();
'trueIn2Seconds-start'
]);
tick(2000); // 4200 expect(log).toEqual([
fixture.detectChanges(); 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway',
'trueIn2Seconds-start'
]);
expect(log).toEqual([ tick(2000); // 4200
'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', fixture.detectChanges();
'trueIn2Seconds-start', 'trueIn2Seconds-end'
]); expect(log).toEqual([
}))); 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway',
'trueIn2Seconds-start', 'trueIn2Seconds-end'
]);
})));
}); });
it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => {
@ -962,7 +970,6 @@ describe('Integration', () => {
locationUrlBeforeEmittingError = location.path(); locationUrlBeforeEmittingError = location.path();
} }
}); });
router.navigateByUrl('/throwing').catch(() => null); router.navigateByUrl('/throwing').catch(() => null);
advance(fixture); advance(fixture);