From 8a1a989a1c8d77ae65e35acb70efd530039ec408 Mon Sep 17 00:00:00 2001 From: Jason Aden Date: Sat, 1 Jul 2017 10:30:17 -0700 Subject: [PATCH] feat(router): add router-level events for GuardsCheck and Resolve (#17601) --- packages/router/src/events.ts | 110 ++++++++++++++++++++-- packages/router/src/index.ts | 2 +- packages/router/src/router.ts | 43 ++++++--- packages/router/src/router_preloader.ts | 3 + packages/router/test/bootstrap.spec.ts | 13 ++- packages/router/test/integration.spec.ts | 45 +++++++-- tools/public_api_guard/router/router.d.ts | 60 +++++++++++- 7 files changed, 246 insertions(+), 30 deletions(-) diff --git a/packages/router/src/events.ts b/packages/router/src/events.ts index c4b223c17e..80564147d3 100644 --- a/packages/router/src/events.ts +++ b/packages/router/src/events.ts @@ -128,18 +128,114 @@ export class RouteConfigLoadEnd { } /** - * @whatItDoes Represents a router event. + * @whatItDoes Represents the start of the Guard phase of routing. + * + * @experimental + */ +export class GuardsCheckStart { + constructor( + /** @docsNotRequired */ + public id: number, + /** @docsNotRequired */ + public url: string, + /** @docsNotRequired */ + public urlAfterRedirects: string, + /** @docsNotRequired */ + public state: RouterStateSnapshot) {} + + toString(): string { + return `GuardsCheckStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; + } +} + +/** + * @whatItDoes Represents the end of the Guard phase of routing. + * + * @experimental + */ +export class GuardsCheckEnd { + constructor( + /** @docsNotRequired */ + public id: number, + /** @docsNotRequired */ + public url: string, + /** @docsNotRequired */ + public urlAfterRedirects: string, + /** @docsNotRequired */ + public state: RouterStateSnapshot, + /** @docsNotRequired */ + public shouldActivate: boolean) {} + + toString(): string { + return `GuardsCheckEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state}, shouldActivate: ${this.shouldActivate})`; + } +} + +/** + * @whatItDoes Represents the start of the Resolve phase of routing. The timing of this + * event may change, thus it's experimental. In the current iteration it will run + * in the "resolve" phase whether there's things to resolve or not. In the future this + * behavior may change to only run when there are things to be resolved. + * + * @experimental + */ +export class ResolveStart { + constructor( + /** @docsNotRequired */ + public id: number, + /** @docsNotRequired */ + public url: string, + /** @docsNotRequired */ + public urlAfterRedirects: string, + /** @docsNotRequired */ + public state: RouterStateSnapshot) {} + + toString(): string { + return `ResolveStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; + } +} + +/** + * @whatItDoes Represents the end of the Resolve phase of routing. See note on + * {@link ResolveStart} for use of this experimental API. + * + * @experimental + */ +export class ResolveEnd { + constructor( + /** @docsNotRequired */ + public id: number, + /** @docsNotRequired */ + public url: string, + /** @docsNotRequired */ + public urlAfterRedirects: string, + /** @docsNotRequired */ + public state: RouterStateSnapshot) {} + + toString(): string { + return `ResolveEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; + } +} + +/** + * @whatItDoes Represents a router event, allowing you to track the lifecycle of the router. + * + * The sequence of router events is: * - * One of: * - {@link NavigationStart}, + * - {@link RouteConfigLoadStart}, + * - {@link RouteConfigLoadEnd}, + * - {@link RoutesRecognized}, + * - {@link GuardsCheckStart}, + * - {@link GuardsCheckEnd}, + * - {@link ResolveStart}, + * - {@link ResolveEnd}, * - {@link NavigationEnd}, * - {@link NavigationCancel}, - * - {@link NavigationError}, - * - {@link RoutesRecognized}, - * - {@link RouteConfigLoadStart}, - * - {@link RouteConfigLoadEnd} + * - {@link NavigationError} * * @stable */ export type Event = NavigationStart | NavigationEnd | NavigationCancel | NavigationError | - RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd; + RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd | GuardsCheckStart | + GuardsCheckEnd | ResolveStart | ResolveEnd; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index f4924f47f8..264929c3fe 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -11,7 +11,7 @@ export {Data, LoadChildren, LoadChildrenCallback, ResolveData, Route, Routes, Ru export {RouterLink, RouterLinkWithHref} from './directives/router_link'; export {RouterLinkActive} from './directives/router_link_active'; export {RouterOutlet} from './directives/router_outlet'; -export {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; +export {Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {NavigationExtras, Router} from './router'; diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index d13c9753b6..429fc14ff6 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -25,7 +25,7 @@ import {applyRedirects} from './apply_redirects'; import {LoadedRouterConfig, QueryParamsHandling, ResolveData, Route, Routes, RunGuardsAndResolvers, validateConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; -import {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; +import {Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; import {recognize} from './recognize'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {RouterConfigLoader} from './router_config_loader'; @@ -639,20 +639,33 @@ export class Router { ({appliedUrl, snapshot}: {appliedUrl: string, snapshot: RouterStateSnapshot}) => { if (this.navigationId !== id) return of (false); + this.triggerEvent( + new GuardsCheckStart(id, this.serializeUrl(url), appliedUrl, snapshot)); + return map.call(preActivation.checkGuards(), (shouldActivate: boolean) => { + this.triggerEvent(new GuardsCheckEnd( + id, this.serializeUrl(url), appliedUrl, snapshot, shouldActivate)); return {appliedUrl: appliedUrl, snapshot: snapshot, shouldActivate: shouldActivate}; }); }); - const preactivationResolveData$ = mergeMap.call(preactivationCheckGuards$, (p: any) => { - if (this.navigationId !== id) return of (false); + const preactivationResolveData$ = mergeMap.call( + preactivationCheckGuards$, + (p: {appliedUrl: string, snapshot: RouterStateSnapshot, shouldActivate: boolean}) => { + if (this.navigationId !== id) return of (false); - if (p.shouldActivate) { - return map.call(preActivation.resolveData(), () => p); - } else { - return of (p); - } - }); + if (p.shouldActivate && preActivation.isActivating()) { + this.triggerEvent( + new ResolveStart(id, this.serializeUrl(url), p.appliedUrl, p.snapshot)); + return map.call(preActivation.resolveData(), () => { + this.triggerEvent( + new ResolveEnd(id, this.serializeUrl(url), p.appliedUrl, p.snapshot)); + return p; + }); + } else { + return of (p); + } + }); const preactivationDone$ = mergeMap.call(preactivationResolveData$, (p: any) => { return map.call(this.hooks.afterPreactivation(p.snapshot), () => p); @@ -772,8 +785,12 @@ export class PreActivation { this.traverseChildRoutes(futureRoot, currRoot, parentContexts, [futureRoot.value]); } + // TODO(jasonaden): Refactor checkGuards and resolveData so they can collect the checks + // and guards before mapping into the observable. Likely remove the observable completely + // and make these pure functions so they are more predictable and don't rely on so much + // external state. checkGuards(): Observable { - if (this.canDeactivateChecks.length === 0 && this.canActivateChecks.length === 0) { + if (!this.isDeactivating() && !this.isActivating()) { return of (true); } const canDeactivate$ = this.runCanDeactivateChecks(); @@ -783,13 +800,17 @@ export class PreActivation { } resolveData(): Observable { - if (this.canActivateChecks.length === 0) return of (null); + if (!this.isActivating()) return of (null); const checks$ = from(this.canActivateChecks); const runningChecks$ = concatMap.call(checks$, (check: CanActivate) => this.runResolve(check.route)); return reduce.call(runningChecks$, (_: any, __: any) => _); } + isDeactivating(): boolean { return this.canDeactivateChecks.length !== 0; } + + isActivating(): boolean { return this.canActivateChecks.length !== 0; } + private traverseChildRoutes( futureNode: TreeNode, currNode: TreeNode|null, contexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { diff --git a/packages/router/src/router_preloader.ts b/packages/router/src/router_preloader.ts index a4a9177064..689861a5f3 100644 --- a/packages/router/src/router_preloader.ts +++ b/packages/router/src/router_preloader.ts @@ -96,6 +96,9 @@ export class RouterPreloader implements OnDestroy { return this.processRoutes(ngModule, this.router.config); } + // TODO(jasonaden): This class relies on code external to the class to call setUpPreloading. If + // this hasn't been done, ngOnDestroy will fail as this.subscription will be undefined. This + // should be refactored. ngOnDestroy(): void { this.subscription.unsubscribe(); } private processRoutes(ngModule: NgModuleRef, routes: Routes): Observable { diff --git a/packages/router/test/bootstrap.spec.ts b/packages/router/test/bootstrap.spec.ts index d1f43c346f..1277345b87 100644 --- a/packages/router/test/bootstrap.spec.ts +++ b/packages/router/test/bootstrap.spec.ts @@ -82,8 +82,10 @@ describe('bootstrap', () => { const router = res.injector.get(Router); const data = router.routerState.snapshot.root.firstChild !.data; expect(data['test']).toEqual('test-data'); - expect(log).toEqual( - ['TestModule', 'NavigationStart', 'RoutesRecognized', 'RootCmp', 'NavigationEnd']); + expect(log).toEqual([ + 'TestModule', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart', 'GuardsCheckEnd', + 'ResolveStart', 'ResolveEnd', 'RootCmp', 'NavigationEnd' + ]); done(); }); }); @@ -116,8 +118,11 @@ describe('bootstrap', () => { platformBrowserDynamic([]).bootstrapModule(TestModule).then(res => { const router = res.injector.get(Router); expect(router.routerState.snapshot.root.firstChild).toBeNull(); - // NavigationEnd has not been emitted yet because bootstrap returned too early - expect(log).toEqual(['TestModule', 'RootCmp', 'NavigationStart', 'RoutesRecognized']); + // ResolveEnd has not been emitted yet because bootstrap returned too early + expect(log).toEqual([ + 'TestModule', 'RootCmp', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart', + 'GuardsCheckEnd', 'ResolveStart' + ]); router.events.subscribe((e) => { if (e instanceof NavigationEnd) { diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index bfbf072606..d0175b7c4c 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -11,7 +11,7 @@ import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactor import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; -import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '@angular/router'; +import {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '@angular/router'; import {Observable} from 'rxjs/Observable'; import {map} from 'rxjs/operator/map'; @@ -422,9 +422,13 @@ describe('Integration', () => { expectEvents(recordedEvents, [ [NavigationStart, '/team/22/user/victor'], [RoutesRecognized, '/team/22/user/victor'], + [GuardsCheckStart, '/team/22/user/victor'], [GuardsCheckEnd, '/team/22/user/victor'], + [ResolveStart, '/team/22/user/victor'], [ResolveEnd, '/team/22/user/victor'], [NavigationEnd, '/team/22/user/victor'], [NavigationStart, '/team/22/user/fedor'], [RoutesRecognized, '/team/22/user/fedor'], + [GuardsCheckStart, '/team/22/user/fedor'], [GuardsCheckEnd, '/team/22/user/fedor'], + [ResolveStart, '/team/22/user/fedor'], [ResolveEnd, '/team/22/user/fedor'], [NavigationEnd, '/team/22/user/fedor'] ]); }))); @@ -681,11 +685,14 @@ describe('Integration', () => { expectEvents(recordedEvents, [ [NavigationStart, '/user/init'], [RoutesRecognized, '/user/init'], - [NavigationEnd, '/user/init'], + [GuardsCheckStart, '/user/init'], [GuardsCheckEnd, '/user/init'], + [ResolveStart, '/user/init'], [ResolveEnd, '/user/init'], [NavigationEnd, '/user/init'], [NavigationStart, '/user/victor'], [NavigationCancel, '/user/victor'], [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], + [GuardsCheckStart, '/user/fedor'], [GuardsCheckEnd, '/user/fedor'], + [ResolveStart, '/user/fedor'], [ResolveEnd, '/user/fedor'], [NavigationEnd, '/user/fedor'] ]); }))); @@ -712,6 +719,8 @@ describe('Integration', () => { [NavigationStart, '/invalid'], [NavigationError, '/invalid'], [NavigationStart, '/user/fedor'], [RoutesRecognized, '/user/fedor'], + [GuardsCheckStart, '/user/fedor'], [GuardsCheckEnd, '/user/fedor'], + [ResolveStart, '/user/fedor'], [ResolveEnd, '/user/fedor'], [NavigationEnd, '/user/fedor'] ]); }))); @@ -965,6 +974,7 @@ describe('Integration', () => { expectEvents(recordedEvents, [ [NavigationStart, '/simple'], [RoutesRecognized, '/simple'], + [GuardsCheckStart, '/simple'], [GuardsCheckEnd, '/simple'], [ResolveStart, '/simple'], [NavigationError, '/simple'] ]); @@ -1382,8 +1392,10 @@ describe('Integration', () => { expect(location.path()).toEqual('/'); expectEvents(recordedEvents, [ [NavigationStart, '/team/22'], [RoutesRecognized, '/team/22'], + [GuardsCheckStart, '/team/22'], [GuardsCheckEnd, '/team/22'], [NavigationCancel, '/team/22'] ]); + expect((recordedEvents[3] as GuardsCheckEnd).shouldActivate).toBe(false); }))); }); @@ -2234,6 +2246,7 @@ describe('Integration', () => { expectEvents(recordedEvents, [ [NavigationStart, '/lazyFalse/loaded'], + // [GuardsCheckStart, '/lazyFalse/loaded'], [NavigationCancel, '/lazyFalse/loaded'], ]); @@ -2250,6 +2263,10 @@ describe('Integration', () => { [RouteConfigLoadStart], [RouteConfigLoadEnd], [RoutesRecognized, '/lazyTrue/loaded'], + [GuardsCheckStart, '/lazyTrue/loaded'], + [GuardsCheckEnd, '/lazyTrue/loaded'], + [ResolveStart, '/lazyTrue/loaded'], + [ResolveEnd, '/lazyTrue/loaded'], [NavigationEnd, '/lazyTrue/loaded'], ]); }))); @@ -2274,8 +2291,14 @@ describe('Integration', () => { expect(location.path()).toEqual('/blank'); expectEvents(recordedEvents, [ - [NavigationStart, '/lazyFalse/loaded'], [NavigationCancel, '/lazyFalse/loaded'], - [NavigationStart, '/blank'], [RoutesRecognized, '/blank'], [NavigationEnd, '/blank'] + [NavigationStart, '/lazyFalse/loaded'], + // No GuardCheck events as `canLoad` is a special guard that's not actually part of the + // guard lifecycle. + [NavigationCancel, '/lazyFalse/loaded'], + + [NavigationStart, '/blank'], [RoutesRecognized, '/blank'], + [GuardsCheckStart, '/blank'], [GuardsCheckEnd, '/blank'], [ResolveStart, '/blank'], + [ResolveEnd, '/blank'], [NavigationEnd, '/blank'] ]); }))); @@ -3174,6 +3197,8 @@ describe('Integration', () => { expect(location.path()).toEqual('/include/user/kate'); expectEvents(events, [ [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], + [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], + [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], [NavigationEnd, '/include/user/kate'] ]); expect(fixture.nativeElement).toHaveText('team [ user kate, right: ]'); @@ -3186,8 +3211,10 @@ describe('Integration', () => { expect(location.path()).toEqual('/exclude/one'); expect(Object.keys(router.routerState.root.children).length).toEqual(0); expect(fixture.nativeElement).toHaveText(''); - expectEvents( - events, [[NavigationStart, '/exclude/one'], [NavigationEnd, '/exclude/one']]); + expectEvents(events, [ + [NavigationStart, '/exclude/one'], [GuardsCheckStart, '/exclude/one'], + [GuardsCheckEnd, '/exclude/one'], [NavigationEnd, '/exclude/one'] + ]); events.splice(0); // another unsupported URL @@ -3206,6 +3233,8 @@ describe('Integration', () => { expectEvents(events, [ [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], + [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], + [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], [NavigationEnd, '/include/simple'] ]); }))); @@ -3231,6 +3260,8 @@ describe('Integration', () => { expect(location.path()).toEqual('/include/user/kate(aux:excluded)'); expectEvents(events, [ [NavigationStart, '/include/user/kate'], [RoutesRecognized, '/include/user/kate'], + [GuardsCheckStart, '/include/user/kate'], [GuardsCheckEnd, '/include/user/kate'], + [ResolveStart, '/include/user/kate'], [ResolveEnd, '/include/user/kate'], [NavigationEnd, '/include/user/kate'] ]); events.splice(0); @@ -3245,6 +3276,8 @@ describe('Integration', () => { expect(location.path()).toEqual('/include/simple(aux:excluded2)'); expectEvents(events, [ [NavigationStart, '/include/simple'], [RoutesRecognized, '/include/simple'], + [GuardsCheckStart, '/include/simple'], [GuardsCheckEnd, '/include/simple'], + [ResolveStart, '/include/simple'], [ResolveEnd, '/include/simple'], [NavigationEnd, '/include/simple'] ]); }))); diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index cf69e51832..f047c253d0 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -87,7 +87,7 @@ export declare class DefaultUrlSerializer implements UrlSerializer { export declare type DetachedRouteHandle = {}; /** @stable */ -export declare type Event = NavigationStart | NavigationEnd | NavigationCancel | NavigationError | RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd; +export declare type Event = NavigationStart | NavigationEnd | NavigationCancel | NavigationError | RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd | GuardsCheckStart | GuardsCheckEnd | ResolveStart | ResolveEnd; /** @stable */ export interface ExtraOptions { @@ -98,6 +98,36 @@ export interface ExtraOptions { useHash?: boolean; } +/** @experimental */ +export declare class GuardsCheckEnd { + id: number; + shouldActivate: boolean; + state: RouterStateSnapshot; + url: string; + urlAfterRedirects: string; + constructor( + id: number, + url: string, + urlAfterRedirects: string, + state: RouterStateSnapshot, + shouldActivate: boolean); + toString(): string; +} + +/** @experimental */ +export declare class GuardsCheckStart { + id: number; + state: RouterStateSnapshot; + url: string; + urlAfterRedirects: string; + constructor( + id: number, + url: string, + urlAfterRedirects: string, + state: RouterStateSnapshot); + toString(): string; +} + /** @stable */ export declare type LoadChildren = string | LoadChildrenCallback; @@ -215,6 +245,34 @@ export declare type ResolveData = { [name: string]: any; }; +/** @experimental */ +export declare class ResolveEnd { + id: number; + state: RouterStateSnapshot; + url: string; + urlAfterRedirects: string; + constructor( + id: number, + url: string, + urlAfterRedirects: string, + state: RouterStateSnapshot); + toString(): string; +} + +/** @experimental */ +export declare class ResolveStart { + id: number; + state: RouterStateSnapshot; + url: string; + urlAfterRedirects: string; + constructor( + id: number, + url: string, + urlAfterRedirects: string, + state: RouterStateSnapshot); + toString(): string; +} + /** @stable */ export interface Route { canActivate?: any[];