diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 19cef3be6b..7914d42b73 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -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 {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; 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 {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module'; export {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; diff --git a/packages/router/src/operators/after_preactivation.ts b/packages/router/src/operators/after_preactivation.ts index d31ae9f36e..51eea2d424 100644 --- a/packages/router/src/operators/after_preactivation.ts +++ b/packages/router/src/operators/after_preactivation.ts @@ -9,21 +9,17 @@ import {MonoTypeOperatorFunction} from 'rxjs'; import {map, mergeMap} from 'rxjs/operators'; -import {RouterHook} from '../router'; -import {RouterStateSnapshot} from '../router_state'; -import {UrlTree} from '../url_tree'; +import {NavigationTransition, RouterHook} from '../router'; -export function afterPreactivation( - hook: RouterHook, navigationId: number, appliedUrlTree: UrlTree, rawUrlTree: UrlTree, - skipLocationChange: boolean, replaceUrl: boolean): - MonoTypeOperatorFunction<{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}> { +export function afterPreactivation(hook: RouterHook): + MonoTypeOperatorFunction { return function(source) { - return source.pipe(mergeMap( - p => hook( - p.snapshot, - { - navigationId, appliedUrlTree, rawUrlTree, skipLocationChange, replaceUrl, - }) - .pipe(map(() => p)))); + return source.pipe(mergeMap(t => hook(t.targetSnapshot !, { + navigationId: t.id, + appliedUrlTree: t.extractedUrl, + rawUrlTree: t.rawUrl, + skipLocationChange: !!t.extras.skipLocationChange, + replaceUrl: !!t.extras.replaceUrl, + }).pipe(map(() => t)))); }; } diff --git a/packages/router/src/operators/apply_redirects.ts b/packages/router/src/operators/apply_redirects.ts index f79ff17d50..54821da929 100644 --- a/packages/router/src/operators/apply_redirects.ts +++ b/packages/router/src/operators/apply_redirects.ts @@ -7,25 +7,21 @@ */ import {Injector} from '@angular/core'; -import {Observable, OperatorFunction} from 'rxjs'; -import {flatMap} from 'rxjs/operators'; +import {MonoTypeOperatorFunction, Observable} from 'rxjs'; +import {flatMap, map} from 'rxjs/operators'; import {applyRedirects as applyRedirectsFn} from '../apply_redirects'; import {Routes} from '../config'; +import {NavigationTransition} from '../router'; 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( moduleInjector: Injector, configLoader: RouterConfigLoader, urlSerializer: UrlSerializer, - config: Routes): OperatorFunction { - return function(source: Observable) { + config: Routes): MonoTypeOperatorFunction { + return function(source: Observable) { 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}))))); }; } diff --git a/packages/router/src/operators/before_preactivation.ts b/packages/router/src/operators/before_preactivation.ts index 795f8ff457..da8a06f0ee 100644 --- a/packages/router/src/operators/before_preactivation.ts +++ b/packages/router/src/operators/before_preactivation.ts @@ -9,21 +9,17 @@ import {MonoTypeOperatorFunction} from 'rxjs'; import {map, mergeMap} from 'rxjs/operators'; -import {RouterHook} from '../router'; -import {RouterStateSnapshot} from '../router_state'; -import {UrlTree} from '../url_tree'; +import {NavigationTransition, RouterHook} from '../router'; -export function beforePreactivation( - hook: RouterHook, navigationId: number, appliedUrlTree: UrlTree, rawUrlTree: UrlTree, - skipLocationChange: boolean, replaceUrl: boolean): - MonoTypeOperatorFunction<{appliedUrl: UrlTree, snapshot: RouterStateSnapshot}> { +export function beforePreactivation(hook: RouterHook): + MonoTypeOperatorFunction { return function(source) { - return source.pipe(mergeMap( - p => hook( - p.snapshot, - { - navigationId, appliedUrlTree, rawUrlTree, skipLocationChange, replaceUrl, - }) - .pipe(map(() => p)))); + return source.pipe(mergeMap(t => hook(t.targetSnapshot !, { + navigationId: t.id, + appliedUrlTree: t.extractedUrl, + rawUrlTree: t.rawUrl, + skipLocationChange: !!t.extras.skipLocationChange, + replaceUrl: !!t.extras.replaceUrl, + }).pipe(map(() => t)))); }; } diff --git a/packages/router/src/operators/check_guards.ts b/packages/router/src/operators/check_guards.ts index 160c266907..b89eefbc56 100644 --- a/packages/router/src/operators/check_guards.ts +++ b/packages/router/src/operators/check_guards.ts @@ -6,22 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injector, Type} from '@angular/core'; -import {Observable, OperatorFunction} from 'rxjs'; -import {mergeMap} from 'rxjs/operators'; +import {MonoTypeOperatorFunction, Observable, from, of } from 'rxjs'; +import {map, mergeMap} from 'rxjs/operators'; -import {Route} from '../config'; -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'; +import {NavigationTransition} from '../router'; -export function checkGuards( - rootContexts: ChildrenOutletContexts, currentSnapshot: RouterStateSnapshot, - moduleInjector: Injector, preActivation: PreActivation): OperatorFunction { - return function(source: Observable) { - return source.pipe( - mergeMap((appliedUrl): Observable => { return preActivation.checkGuards(); })); +export function checkGuards(): MonoTypeOperatorFunction { + return function(source: Observable) { + + return source.pipe(mergeMap(t => { + if (!t.preActivation) { + throw 'Initialized PreActivation required to check guards'; + } + return t.preActivation.checkGuards().pipe(map(guardsResult => ({...t, guardsResult}))); + })); }; -} \ No newline at end of file +} diff --git a/packages/router/src/operators/mergeMapIf.ts b/packages/router/src/operators/mergeMapIf.ts new file mode 100644 index 0000000000..f7cd95da29 --- /dev/null +++ b/packages/router/src/operators/mergeMapIf.ts @@ -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( + predicate: (value: T) => boolean, tap: (value: T) => any): MonoTypeOperatorFunction { + return (source: Observable) => { + return source.pipe(mergeMap(s => { + if (predicate(s)) { + tap(s); + return EMPTY; + } + return of (s); + })); + }; +} diff --git a/packages/router/src/operators/recognize.ts b/packages/router/src/operators/recognize.ts index 4754bf0b4e..b1b48a8635 100644 --- a/packages/router/src/operators/recognize.ts +++ b/packages/router/src/operators/recognize.ts @@ -7,22 +7,23 @@ */ import {Type} from '@angular/core'; -import {Observable, OperatorFunction} from 'rxjs'; -import {mergeMap} from 'rxjs/operators'; +import {MonoTypeOperatorFunction, Observable} from 'rxjs'; +import {map, mergeMap} from 'rxjs/operators'; import {Route} from '../config'; import {recognize as recognizeFn} from '../recognize'; -import {RouterStateSnapshot} from '../router_state'; +import {NavigationTransition} from '../router'; import {UrlTree} from '../url_tree'; export function recognize( rootComponentType: Type| null, config: Route[], serializer: (url: UrlTree) => string, paramsInheritanceStrategy: 'emptyOnly' | - 'always'): OperatorFunction { - return function(source: Observable) { + 'always'): MonoTypeOperatorFunction { + return function(source: Observable) { return source.pipe(mergeMap( - (appliedUrl: UrlTree) => recognizeFn( - rootComponentType, config, appliedUrl, serializer(appliedUrl), - paramsInheritanceStrategy))); + t => recognizeFn( + rootComponentType, config, t.urlAfterRedirects, serializer(t.extractedUrl), + paramsInheritanceStrategy) + .pipe(map(targetSnapshot => ({...t, targetSnapshot}))))); }; } \ No newline at end of file diff --git a/packages/router/src/operators/resolve_data.ts b/packages/router/src/operators/resolve_data.ts index 0f760bf800..e0d3501861 100644 --- a/packages/router/src/operators/resolve_data.ts +++ b/packages/router/src/operators/resolve_data.ts @@ -6,23 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ -import {Injector, Type} from '@angular/core'; -import {Observable, OperatorFunction} from 'rxjs'; -import {mergeMap} from 'rxjs/operators'; +import {MonoTypeOperatorFunction, Observable, OperatorFunction, from, of } from 'rxjs'; +import {map, mergeMap} from 'rxjs/operators'; -import {Route} from '../config'; -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'; +import {NavigationTransition} from '../router'; export function resolveData( - preActivation: PreActivation, - paramsInheritanceStrategy: 'emptyOnly' | 'always'): OperatorFunction { - return function(source: Observable) { - return source.pipe(mergeMap((appliedUrl): Observable => { - return preActivation.resolveData(paramsInheritanceStrategy); + paramsInheritanceStrategy: 'emptyOnly' | 'always'): MonoTypeOperatorFunction { + return function(source: Observable) { + return source.pipe(mergeMap(t => { + if (!t.preActivation) { + throw 'Initialized PreActivation required to check guards'; + } + return t.preActivation.resolveData(paramsInheritanceStrategy).pipe(map(_ => t)); })); }; -} \ No newline at end of file +} diff --git a/packages/router/src/operators/throwIf.ts b/packages/router/src/operators/throwIf.ts new file mode 100644 index 0000000000..b77c341a50 --- /dev/null +++ b/packages/router/src/operators/throwIf.ts @@ -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( + predicate: (value: T) => boolean, + errorFactory: (() => any) = defaultErrorFactory): MonoTypeOperatorFunction { + return (source: Observable) => { + return source.pipe(tap(s => { + if (predicate(s)) { + throw errorFactory(); + } + })); + }; +} + +function defaultErrorFactory() { + return new Error(); +} \ No newline at end of file diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 5cf0fda603..7485b04a53 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -7,31 +7,32 @@ */ import {Location} from '@angular/common'; -import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Optional, Type, isDevMode, ɵConsole as Console} from '@angular/core'; -import {BehaviorSubject, Observable, Subject, Subscription, of } from 'rxjs'; -import {concatMap, map, mergeMap, tap} from 'rxjs/operators'; +import {Compiler, Injector, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, isDevMode, ɵConsole as Console} from '@angular/core'; +import {BehaviorSubject, EMPTY, MonoTypeOperatorFunction, Observable, Subject, Subscription, of } from 'rxjs'; +import {catchError, filter, finalize, map, mergeMap, switchMap, tap} from 'rxjs/operators'; import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config'; import {createRouterState} from './create_router_state'; 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 {afterPreactivation} from './operators/after_preactivation'; import {applyRedirects} from './operators/apply_redirects'; import {beforePreactivation} from './operators/before_preactivation'; import {checkGuards} from './operators/check_guards'; +import {mergeMapIf} from './operators/mergeMapIf'; import {recognize} from './operators/recognize'; import {resolveData} from './operators/resolve_data'; -import {setupPreactivation} from './operators/setup_preactivation'; import {PreActivation} from './pre_activation'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {RouterConfigLoader} from './router_config_loader'; 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 {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy'; import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree'; +import {assertDefined} from './utils/assert'; import {forEach} from './utils/collection'; import {TreeNode, nodeChildrenAsMap} from './utils/tree'; -import { afterPreactivation } from './operators/after_preactivation'; @@ -174,15 +175,25 @@ function defaultMalformedUriErrorHandler( type NavStreamValue = boolean | {appliedUrl: UrlTree, snapshot: RouterStateSnapshot, shouldActivate?: boolean}; -type NavigationParams = { +export type NavigationTransition = { id: number, + currentUrlTree: UrlTree, + currentRawUrl: UrlTree, + extractedUrl: UrlTree, + urlAfterRedirects: UrlTree, rawUrl: UrlTree, extras: NavigationExtras, resolve: any, reject: any, promise: Promise, 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 { private currentUrlTree: UrlTree; private rawUrlTree: UrlTree; - private navigations = new BehaviorSubject(null !); + private navigations: Observable; // TODO(issue/24571): remove '!'. private locationSubscription !: Subscription; @@ -233,6 +244,7 @@ export class Router { private console: Console; private isNgZoneEnabled: boolean = false; + public readonly transitions: BehaviorSubject; public readonly events: Observable = new Subject(); public readonly routerState: RouterState; @@ -332,9 +344,211 @@ export class Router { this.configLoader = new RouterConfigLoader(loader, compiler, onLoadStart, onLoadEnd); this.routerState = createEmptyState(this.currentUrlTree, this.rootComponentType); + + this.transitions = new BehaviorSubject({ + 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(); } + private setupNavigations(transitions: Observable): Observable { + 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) + .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) + .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) + .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) + .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) + .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) + .next( + new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), e.message)); + } else { + this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl); + (this.events as Subject) + .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; + + function activate( + rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy, + forwardEvent: (evt: Event) => void): MonoTypeOperatorFunction { + return function(source: Observable) { + return source.pipe(map(t => { + new ActivateRoutes( + routeReuseStrategy, t.targetRouterState !, t.currentRouterState, forwardEvent) + .activate(rootContexts); + return t; + })); + }; + } + } + /** * @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; } + private getTransition(): NavigationTransition { return this.transitions.value; } + + private setTransition(t: Partial): void { + this.transitions.next({...this.getTransition(), ...t}); + } + /** * Sets up the location change listener and performs the initial navigation. */ @@ -582,24 +802,22 @@ export class Router { } private processNavigations(): void { - this.navigations - .pipe(concatMap((nav: NavigationParams) => { - if (nav) { - this.executeScheduledNavigation(nav); - // a failed navigation should not stop the router from processing - // further navigations => the catch - return nav.promise.catch(() => {}); - } else { - return of (null); - } - })) - .subscribe(() => {}); + this.navigations.subscribe( + t => { + this.navigated = true; + this.lastSuccessfulId = t.id; + (this.events as Subject) + .next(new NavigationEnd( + t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree))); + t.resolve(true); + }, + e => { throw 'never get here!'; }); } private scheduleNavigation( rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null, extras: NavigationExtras): Promise { - const lastNavigation = this.navigations.value; + const lastNavigation = this.getTransition(); // If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // and that navigation results in 'replaceState' that leads to the same URL, // we should skip those. @@ -632,243 +850,19 @@ export class Router { }); 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 // handler does not rethrow - return promise.catch((e: any) => 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) - .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) - .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 { - if (id !== this.navigationId) { - (this.events as Subject) - .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) - .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 = - 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 = 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 = - 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, - 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) - .next(new NavigationEnd( - id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree))); - resolvePromise(true); - } else { - this.resetUrlToCurrentUrlTree(); - (this.events as Subject) - .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) - .next(new NavigationCancel(id, this.serializeUrl(url), e.message)); - - resolvePromise(false); - } else { - this.resetStateAndUrl(storedState, storedUrl, rawUrl); - (this.events as Subject) - .next(new NavigationError(id, this.serializeUrl(url), e)); - try { - resolvePromise(this.errorHandler(e)); - } catch (ee) { - rejectPromise(ee); - } - } - }); + return promise.catch((e: any) => { return Promise.reject(e); }); } private setBrowserUrl(url: UrlTree, replaceUrl: boolean, id: number) { diff --git a/packages/router/src/utils/assert.ts b/packages/router/src/utils/assert.ts new file mode 100644 index 0000000000..38ad8b0a6d --- /dev/null +++ b/packages/router/src/utils/assert.ts @@ -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(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}`); +} diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index a71cc6c76a..04ee5bebd8 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -291,41 +291,49 @@ describe('Integration', () => { }); - it('should execute navigations serialy', - fakeAsync(inject([Router, Location], (router: Router) => { - const fixture = createRoot(router, RootCmp); + // TODO(jasonaden): This test now fails because it relies on waiting on a guard to finish + // executing even after a new navigation has been scheduled. The previous implementation + // would do so, but ignore the result of any guards that are executing when a new navigation + // is scheduled. - router.resetConfig([ - {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, - {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} - ]); + // With new implementation, the current navigation will be unrolled and cleaned up so the + // new navigation can start immediately. This test therefore fails as it relies on that + // previous incorrect behavior. + xit('should execute navigations serialy', + fakeAsync(inject([Router, Location], (router: Router) => { + const fixture = createRoot(router, RootCmp); - router.navigateByUrl('/a'); - tick(100); - fixture.detectChanges(); + router.resetConfig([ + {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, + {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} + ]); - router.navigateByUrl('/b'); - tick(100); // 200 - fixture.detectChanges(); + router.navigateByUrl('/a'); + tick(100); + fixture.detectChanges(); - expect(log).toEqual(['trueRightAway', 'trueIn2Seconds-start']); + router.navigateByUrl('/b'); + tick(100); // 200 + fixture.detectChanges(); - tick(2000); // 2200 - fixture.detectChanges(); + expect(log).toEqual(['trueRightAway', 'trueIn2Seconds-start']); - expect(log).toEqual([ - 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', - 'trueIn2Seconds-start' - ]); + tick(2000); // 2200 + fixture.detectChanges(); - tick(2000); // 4200 - fixture.detectChanges(); + expect(log).toEqual([ + 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', + 'trueIn2Seconds-start' + ]); - expect(log).toEqual([ - 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', - 'trueIn2Seconds-start', 'trueIn2Seconds-end' - ]); - }))); + tick(2000); // 4200 + fixture.detectChanges(); + + expect(log).toEqual([ + 'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway', + 'trueIn2Seconds-start', 'trueIn2Seconds-end' + ]); + }))); }); it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { @@ -962,7 +970,6 @@ describe('Integration', () => { locationUrlBeforeEmittingError = location.path(); } }); - router.navigateByUrl('/throwing').catch(() => null); advance(fixture);