refactor(router): update test based on router quick-cancelling ongoing navigations (#25740)

PR Close #25740
This commit is contained in:
Jason Aden 2018-09-11 22:18:50 -07:00 committed by Kara Erickson
parent c634176035
commit 12ccf57340
6 changed files with 205 additions and 284 deletions

View File

@ -1,23 +0,0 @@
/**
* @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

@ -11,8 +11,8 @@ import {map, mergeMap} from 'rxjs/operators';
import {NavigationTransition} from '../router'; import {NavigationTransition} from '../router';
export function resolveData( export function resolveData(paramsInheritanceStrategy: 'emptyOnly' | 'always'):
paramsInheritanceStrategy: 'emptyOnly' | 'always'): MonoTypeOperatorFunction<NavigationTransition> { MonoTypeOperatorFunction<NavigationTransition> {
return function(source: Observable<NavigationTransition>) { return function(source: Observable<NavigationTransition>) {
return source.pipe(mergeMap(t => { return source.pipe(mergeMap(t => {
if (!t.preActivation) { if (!t.preActivation) {

View File

@ -1,26 +0,0 @@
/**
* @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

@ -19,7 +19,6 @@ 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 {PreActivation} from './pre_activation'; import {PreActivation} from './pre_activation';
@ -30,7 +29,6 @@ import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot
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';
@ -370,179 +368,184 @@ export class Router {
this.processNavigations(); this.processNavigations();
} }
// clang-format off
private setupNavigations(transitions: Observable<NavigationTransition>): private setupNavigations(transitions: Observable<NavigationTransition>):
Observable<NavigationTransition> { Observable<NavigationTransition> {
return transitions.pipe( return transitions.pipe(
filter(t => t.id !== 0), mergeMap(t => Promise.resolve(t)), filter(t => t.id !== 0), mergeMap(t => Promise.resolve(t)),
// 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 // Extract URL
switchMap(t => { map(t => ({
let completed = false; ...t,
let errored = false; extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl)} as NavigationTransition)),
return of (t).pipe(
// 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
mergeMap(t => { mergeMap(t => {
const urlTransition = const transition = this.transitions.getValue();
!this.navigated || t.extractedUrl.toString() !== this.currentUrlTree.toString(); (this.events as Subject<Event>).next(new NavigationStart(
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) && t.id, this.serializeUrl(t.extractedUrl), t.source, t.state));
this.urlHandlingStrategy.shouldProcessUrl(t.rawUrl)) { if (transition !== this.transitions.getValue()) {
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
mergeMap(t => {
const transition = this.transitions.getValue();
(this.events as Subject<Event>)
.next(new NavigationStart(
t.id, this.serializeUrl(t.extractedUrl), t.source, t.state));
if (transition !== this.transitions.getValue()) {
EMPTY;
}
return [t];
}),
// 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; return EMPTY;
} }
return [t];
}), }),
// Before Preactivation // This delay is required to match old behavior that forced navigation to
beforePreactivation(this.hooks.beforePreactivation), // always be async
// --- GUARDS --- mergeMap(t => Promise.resolve(t)),
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( // ApplyRedirects
t => !t.guardsResult, applyRedirects(
t => { this.ngModule.injector, this.configLoader, this.urlSerializer,this.config),
this.resetUrlToCurrentUrlTree(); // Recognize
(this.events as Subject<Event>) recognize(
.next(new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), '')); this.rootComponentType, this.config, (url) => this.serializeUrl(url),
t.resolve(false); this.paramsInheritanceStrategy),
}),
// --- RESOLVE --- // Fire RoutesRecognized
mergeMap(t => { tap(t => (this.events as Subject<Event>).next(new RoutesRecognized(
if (t.preActivation !.isActivating()) { t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(t.urlAfterRedirects),
return of (t).pipe( t.targetSnapshot !)))
tap(t => this.triggerEvent(new ResolveStart( );
t.id, this.serializeUrl(t.extractedUrl), } else if (
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))), urlTransition && this.rawUrlTree &&
resolveData(this.paramsInheritanceStrategy), this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
tap(t => this.triggerEvent(new ResolveEnd( (this.events as Subject<Event>).next(new NavigationStart(
t.id, this.serializeUrl(t.extractedUrl), t.id, this.serializeUrl(t.extractedUrl), t.source, t.state));
this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))), );
}
return of (t);
}),
// --- AFTER PREACTIVATION --- return of ({
afterPreactivation(this.hooks.afterPreactivation), map(t => { ...t,
const targetRouterState = createRouterState( urlAfterRedirects: t.extractedUrl,
this.routeReuseStrategy, t.targetSnapshot !, t.currentRouterState); extras: {...t.extras, skipLocationChange: false, replaceUrl: false},
return ({...t, targetRouterState}); targetSnapshot: createEmptyState(t.extractedUrl, this.rootComponentType).snapshot
}), });
} else {
this.rawUrlTree = t.rawUrl;
t.resolve(null);
return EMPTY;
}
}),
// Side effects of resetting Router instance // Before Preactivation
tap(t => { beforePreactivation(this.hooks.beforePreactivation),
this.currentUrlTree = t.urlAfterRedirects;
this.rawUrlTree = this.urlHandlingStrategy.merge(this.currentUrlTree, t.rawUrl);
(this as{routerState: RouterState}).routerState = t.targetRouterState !; // --- 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};
}),
if (this.urlUpdateStrategy === 'deferred' && !t.extras.skipLocationChange) { checkGuards(),
this.setBrowserUrl(this.rawUrlTree, !!t.extras.replaceUrl, t.id);
}
}),
activate( tap(t => this.triggerEvent(new GuardsCheckEnd(
this.rootContexts, this.routeReuseStrategy, t.id, this.serializeUrl(t.extractedUrl),
(evt: Event) => this.triggerEvent(evt)), this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !,
!!t.guardsResult))),
tap({next: () => completed = true, complete: () => completed = true}), mergeMap(t => {
finalize(() => { if (!t.guardsResult) {
if (!completed && !errored) { this.resetUrlToCurrentUrlTree();
(this.events as Subject<Event>) (this.events as Subject<Event>)
.next(new NavigationCancel( .next(new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), ''));
t.id, this.serializeUrl(t.extractedUrl), t.resolve(false);
`Navigation ID ${t.id} is not equal to the current navigation id ${this.navigationId}`)); return EMPTY;
t.resolve(false); }
} return of(t);
}), }),
catchError((e) => {
errored = true; // --- RESOLVE ---
if (isNavigationCancelingError(e)) { mergeMap(t => {
this.navigated = true; if (t.preActivation !.isActivating()) {
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl); return of (t).pipe(
(this.events as Subject<Event>) tap(t => this.triggerEvent(new ResolveStart(
.next( t.id, this.serializeUrl(t.extractedUrl),
new NavigationCancel(t.id, this.serializeUrl(t.extractedUrl), e.message)); this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))),
} else { resolveData(this.paramsInheritanceStrategy),
this.resetStateAndUrl(t.currentRouterState, t.currentUrlTree, t.rawUrl); tap(t => this.triggerEvent(new ResolveEnd(
(this.events as Subject<Event>) t.id, this.serializeUrl(t.extractedUrl),
.next(new NavigationError(t.id, this.serializeUrl(t.extractedUrl), e)); this.serializeUrl(t.urlAfterRedirects), t.targetSnapshot !))), );
try { }
t.resolve(this.errorHandler(e)); return of (t);
} catch (ee) { }),
t.reject(ee);
} // --- AFTER PREACTIVATION ---
} afterPreactivation(this.hooks.afterPreactivation), map(t => {
return EMPTY; const targetRouterState = createRouterState(
}), ); this.routeReuseStrategy, t.targetSnapshot !, t.currentRouterState);
})) as any as Observable<NavigationTransition>; return ({...t, targetRouterState});
}),
// Side effects of resetting Router instance
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( function activate(
rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy, rootContexts: ChildrenOutletContexts, routeReuseStrategy: RouteReuseStrategy,
@ -557,6 +560,7 @@ export class Router {
}; };
} }
} }
// clang-format on
/** /**
* @internal * @internal

View File

@ -1,19 +0,0 @@
/**
* @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,49 +291,35 @@ describe('Integration', () => {
}); });
// TODO(jasonaden): This test now fails because it relies on waiting on a guard to finish it('should not wait for prior navigations to start a new navigation',
// executing even after a new navigation has been scheduled. The previous implementation fakeAsync(inject([Router, Location], (router: Router) => {
// would do so, but ignore the result of any guards that are executing when a new navigation const fixture = createRoot(router, RootCmp);
// is scheduled.
// With new implementation, the current navigation will be unrolled and cleaned up so the router.resetConfig([
// new navigation can start immediately. This test therefore fails as it relies on that {path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']},
// previous incorrect behavior. {path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}
xit('should execute navigations serialy', ]);
fakeAsync(inject([Router, Location], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([ router.navigateByUrl('/a');
{path: 'a', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']}, tick(100);
{path: 'b', component: SimpleCmp, canActivate: ['trueRightAway', 'trueIn2Seconds']} fixture.detectChanges();
]);
router.navigateByUrl('/a'); router.navigateByUrl('/b');
tick(100); tick(100); // 200
fixture.detectChanges(); fixture.detectChanges();
router.navigateByUrl('/b'); expect(log).toEqual(
tick(100); // 200 ['trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start']);
fixture.detectChanges();
expect(log).toEqual(['trueRightAway', 'trueIn2Seconds-start']); tick(2000); // 2200
fixture.detectChanges();
tick(2000); // 2200 expect(log).toEqual([
fixture.detectChanges(); 'trueRightAway', 'trueIn2Seconds-start', 'trueRightAway', 'trueIn2Seconds-start',
'trueIn2Seconds-end', 'trueIn2Seconds-end'
]);
expect(log).toEqual([ })));
'trueRightAway', 'trueIn2Seconds-start', 'trueIn2Seconds-end', 'trueRightAway',
'trueIn2Seconds-start'
]);
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(() => { it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => {
@ -2971,32 +2957,31 @@ describe('Integration', () => {
]); ]);
}))); })));
it('should allow redirection in NavigationStart', fakeAsync(inject([Router], (router: Router) => { it('should allow redirection in NavigationStart',
const fixture = createRoot(router, RootCmp); fakeAsync(inject([Router], (router: Router) => {
const fixture = createRoot(router, RootCmp);
router.resetConfig([ router.resetConfig([
{path: 'blank', component: UserCmp}, {path: 'blank', component: UserCmp},
{path: 'user/:name', component: BlankCmp}, {path: 'user/:name', component: BlankCmp},
]); ]);
const navigateSpy = spyOn(router, 'navigate').and.callThrough(); const navigateSpy = spyOn(router, 'navigate').and.callThrough();
const recordedEvents: any[] = []; const recordedEvents: any[] = [];
const navStart$ = router.events.pipe( const navStart$ = router.events.pipe(
tap(e => recordedEvents.push(e)), tap(e => recordedEvents.push(e)), filter(e => e instanceof NavigationStart), first());
filter(e => e instanceof NavigationStart),
first()
);
navStart$.subscribe((e: NavigationStart | NavigationError) => { navStart$.subscribe((e: NavigationStart | NavigationError) => {
router.navigate(['/blank'], {queryParams: {state: 'redirected'}, queryParamsHandling: 'merge'}); router.navigate(
advance(fixture); ['/blank'], {queryParams: {state: 'redirected'}, queryParamsHandling: 'merge'});
}); advance(fixture);
});
router.navigate(['/user/:fedor']); router.navigate(['/user/:fedor']);
advance(fixture); advance(fixture);
expect(navigateSpy.calls.mostRecent().args[1].queryParams); expect(navigateSpy.calls.mostRecent().args[1].queryParams);
}))); })));
}); });