diff --git a/packages/router/src/events.ts b/packages/router/src/events.ts index 80564147d3..b1cc203dff 100644 --- a/packages/router/src/events.ts +++ b/packages/router/src/events.ts @@ -10,17 +10,66 @@ import {Route} from './config'; import {RouterStateSnapshot} from './router_state'; /** - * @whatItDoes Represents an event triggered when a navigation starts. + * @whatItDoes Base for events the Router goes through, as opposed to events tied to a specific + * Route. `RouterEvent`s will only be fired one time for any given navigation. * - * @stable + * Example: + * + * ``` + * class MyService { + * constructor(public router: Router, logger: Logger) { + * router.events.filter(e => e instanceof RouterEvent).subscribe(e => { + * logger.log(e.id, e.url); + * }); + * } + * } + * ``` + * + * @experimental */ -export class NavigationStart { +export class RouterEvent { constructor( /** @docsNotRequired */ public id: number, /** @docsNotRequired */ public url: string) {} +} +/** + * @whatItDoes Base for events tied to a specific `Route`, as opposed to events for the Router + * lifecycle. `RouteEvent`s may be fired multiple times during a single navigation and will + * always receive the `Route` they pertain to. + * + * Example: + * + * ``` + * class MyService { + * constructor(public router: Router, spinner: Spinner) { + * router.events.filter(e => e instanceof RouteEvent).subscribe(e => { + * if (e instanceof ChildActivationStart) { + * spinner.start(e.route); + * } else if (e instanceof ChildActivationEnd) { + * spinner.end(e.route); + * } + * }); + * } + * } + * ``` + * + * @experimental + */ +export class RouteEvent { + constructor( + /** @docsNotRequired */ + public route: Route) {} +} + +/** + * @whatItDoes Represents an event triggered when a navigation starts. + * + * @stable + */ +export class NavigationStart extends RouterEvent { /** @docsNotRequired */ toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; } } @@ -30,14 +79,16 @@ export class NavigationStart { * * @stable */ -export class NavigationEnd { +export class NavigationEnd extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ - public urlAfterRedirects: string) {} + public urlAfterRedirects: string) { + super(id, url); + } /** @docsNotRequired */ toString(): string { @@ -50,14 +101,16 @@ export class NavigationEnd { * * @stable */ -export class NavigationCancel { +export class NavigationCancel extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ - public reason: string) {} + public reason: string) { + super(id, url); + } /** @docsNotRequired */ toString(): string { return `NavigationCancel(id: ${this.id}, url: '${this.url}')`; } @@ -68,14 +121,16 @@ export class NavigationCancel { * * @stable */ -export class NavigationError { +export class NavigationError extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ - public error: any) {} + public error: any) { + super(id, url); + } /** @docsNotRequired */ toString(): string { @@ -88,16 +143,18 @@ export class NavigationError { * * @stable */ -export class RoutesRecognized { +export class RoutesRecognized extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ public urlAfterRedirects: string, /** @docsNotRequired */ - public state: RouterStateSnapshot) {} + public state: RouterStateSnapshot) { + super(id, url); + } /** @docsNotRequired */ toString(): string { @@ -105,43 +162,23 @@ export class RoutesRecognized { } } -/** - * @whatItDoes Represents an event triggered before lazy loading a route config. - * - * @experimental - */ -export class RouteConfigLoadStart { - constructor(public route: Route) {} - - toString(): string { return `RouteConfigLoadStart(path: ${this.route.path})`; } -} - -/** - * @whatItDoes Represents an event triggered when a route has been lazy loaded. - * - * @experimental - */ -export class RouteConfigLoadEnd { - constructor(public route: Route) {} - - toString(): string { return `RouteConfigLoadEnd(path: ${this.route.path})`; } -} - /** * @whatItDoes Represents the start of the Guard phase of routing. * * @experimental */ -export class GuardsCheckStart { +export class GuardsCheckStart extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ public urlAfterRedirects: string, /** @docsNotRequired */ - public state: RouterStateSnapshot) {} + public state: RouterStateSnapshot) { + super(id, url); + } toString(): string { return `GuardsCheckStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; @@ -153,18 +190,20 @@ export class GuardsCheckStart { * * @experimental */ -export class GuardsCheckEnd { +export class GuardsCheckEnd extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ public urlAfterRedirects: string, /** @docsNotRequired */ public state: RouterStateSnapshot, /** @docsNotRequired */ - public shouldActivate: boolean) {} + public shouldActivate: boolean) { + super(id, url); + } toString(): string { return `GuardsCheckEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state}, shouldActivate: ${this.shouldActivate})`; @@ -179,16 +218,18 @@ export class GuardsCheckEnd { * * @experimental */ -export class ResolveStart { +export class ResolveStart extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ public urlAfterRedirects: string, /** @docsNotRequired */ - public state: RouterStateSnapshot) {} + public state: RouterStateSnapshot) { + super(id, url); + } toString(): string { return `ResolveStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; @@ -201,22 +242,62 @@ export class ResolveStart { * * @experimental */ -export class ResolveEnd { +export class ResolveEnd extends RouterEvent { constructor( /** @docsNotRequired */ - public id: number, + id: number, /** @docsNotRequired */ - public url: string, + url: string, /** @docsNotRequired */ public urlAfterRedirects: string, /** @docsNotRequired */ - public state: RouterStateSnapshot) {} + public state: RouterStateSnapshot) { + super(id, url); + } toString(): string { return `ResolveEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`; } } +/** + * @whatItDoes Represents an event triggered before lazy loading a route config. + * + * @experimental + */ +export class RouteConfigLoadStart extends RouteEvent { + toString(): string { return `RouteConfigLoadStart(path: ${this.route.path})`; } +} + +/** + * @whatItDoes Represents an event triggered when a route has been lazy loaded. + * + * @experimental + */ +export class RouteConfigLoadEnd extends RouteEvent { + toString(): string { return `RouteConfigLoadEnd(path: ${this.route.path})`; } +} + +/** + * @whatItDoes Represents the start of end of the Resolve phase of routing. See note on + * {@link ChildActivationEnd} for use of this experimental API. + * + * @experimental + */ +export class ChildActivationStart extends RouteEvent { + toString(): string { return `ChildActivationStart(path: '${this.route.path}')`; } +} + +/** + * @whatItDoes Represents the start of end of the Resolve phase of routing. See note on + * {@link ChildActivationStart} for use of this experimental API. + * + * @experimental + */ +export class ChildActivationEnd extends RouteEvent { + toString(): string { return `ChildActivationEnd(path: '${this.route.path}')`; } +} + /** * @whatItDoes Represents a router event, allowing you to track the lifecycle of the router. * @@ -227,15 +308,15 @@ export class ResolveEnd { * - {@link RouteConfigLoadEnd}, * - {@link RoutesRecognized}, * - {@link GuardsCheckStart}, + * - {@link ChildActivationStart}, * - {@link GuardsCheckEnd}, * - {@link ResolveStart}, * - {@link ResolveEnd}, + * - {@link ChildActivationEnd} * - {@link NavigationEnd}, * - {@link NavigationCancel}, * - {@link NavigationError} * * @stable */ -export type Event = NavigationStart | NavigationEnd | NavigationCancel | NavigationError | - RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd | GuardsCheckStart | - GuardsCheckEnd | ResolveStart | ResolveEnd; +export type Event = RouterEvent | RouteEvent; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 6d202fbc3d..75abe1211a 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, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; +export {ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteEvent, 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/pre_activation.ts b/packages/router/src/pre_activation.ts new file mode 100644 index 0000000000..e7d29466db --- /dev/null +++ b/packages/router/src/pre_activation.ts @@ -0,0 +1,347 @@ +/** + * @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 {Injector} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {from} from 'rxjs/observable/from'; +import {of } from 'rxjs/observable/of'; +import {concatMap} from 'rxjs/operator/concatMap'; +import {every} from 'rxjs/operator/every'; +import {first} from 'rxjs/operator/first'; +import {last} from 'rxjs/operator/last'; +import {map} from 'rxjs/operator/map'; +import {mergeMap} from 'rxjs/operator/mergeMap'; +import {reduce} from 'rxjs/operator/reduce'; + +import {LoadedRouterConfig, ResolveData, RunGuardsAndResolvers} from './config'; +import {ChildActivationStart, RouteEvent} from './events'; +import {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; +import {ActivatedRouteSnapshot, RouterStateSnapshot, equalParamsAndUrlSegments, inheritedParamsDataResolve} from './router_state'; +import {andObservables, forEach, shallowEqual, wrapIntoObservable} from './utils/collection'; +import {TreeNode, nodeChildrenAsMap} from './utils/tree'; + +class CanActivate { + constructor(public path: ActivatedRouteSnapshot[]) {} + get route(): ActivatedRouteSnapshot { return this.path[this.path.length - 1]; } +} + +class CanDeactivate { + constructor(public component: Object|null, public route: ActivatedRouteSnapshot) {} +} + +/** + * This class bundles the actions involved in preactivation of a route. + */ +export class PreActivation { + private canActivateChecks: CanActivate[] = []; + private canDeactivateChecks: CanDeactivate[] = []; + + constructor( + private future: RouterStateSnapshot, private curr: RouterStateSnapshot, + private moduleInjector: Injector, private forwardEvent?: (evt: RouteEvent) => void) {} + + initalize(parentContexts: ChildrenOutletContexts): void { + const futureRoot = this.future._root; + const currRoot = this.curr ? this.curr._root : null; + this.setupChildRouteGuards(futureRoot, currRoot, parentContexts, [futureRoot.value]); + } + + checkGuards(): Observable { + if (!this.isDeactivating() && !this.isActivating()) { + return of (true); + } + const canDeactivate$ = this.runCanDeactivateChecks(); + return mergeMap.call( + canDeactivate$, + (canDeactivate: boolean) => canDeactivate ? this.runCanActivateChecks() : of (false)); + } + + resolveData(): Observable { + 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; } + + + /** + * Iterates over child routes and calls recursive `setupRouteGuards` to get `this` instance in + * proper state to run `checkGuards()` method. + */ + private setupChildRouteGuards( + futureNode: TreeNode, currNode: TreeNode|null, + contexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { + const prevChildren = nodeChildrenAsMap(currNode); + + // Process the children of the future route + futureNode.children.forEach(c => { + this.setupRouteGuards( + c, prevChildren[c.value.outlet], contexts, futurePath.concat([c.value])); + delete prevChildren[c.value.outlet]; + }); + + // Process any children left from the current route (not active for the future route) + forEach( + prevChildren, (v: TreeNode, k: string) => + this.deactivateRouteAndItsChildren(v, contexts !.getContext(k))); + } + + /** + * Iterates over child routes and calls recursive `setupRouteGuards` to get `this` instance in + * proper state to run `checkGuards()` method. + */ + private setupRouteGuards( + futureNode: TreeNode, currNode: TreeNode, + parentContexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { + const future = futureNode.value; + const curr = currNode ? currNode.value : null; + const context = parentContexts ? parentContexts.getContext(futureNode.value.outlet) : null; + + // reusing the node + if (curr && future._routeConfig === curr._routeConfig) { + const shouldRunGuardsAndResolvers = this.shouldRunGuardsAndResolvers( + curr, future, future._routeConfig !.runGuardsAndResolvers); + if (shouldRunGuardsAndResolvers) { + this.canActivateChecks.push(new CanActivate(futurePath)); + } else { + // we need to set the data + future.data = curr.data; + future._resolvedData = curr._resolvedData; + } + + // If we have a component, we need to go through an outlet. + if (future.component) { + this.setupChildRouteGuards( + futureNode, currNode, context ? context.children : null, futurePath); + + // if we have a componentless route, we recurse but keep the same outlet map. + } else { + this.setupChildRouteGuards(futureNode, currNode, parentContexts, futurePath); + } + + if (shouldRunGuardsAndResolvers) { + const outlet = context !.outlet !; + this.canDeactivateChecks.push(new CanDeactivate(outlet.component, curr)); + } + } else { + if (curr) { + this.deactivateRouteAndItsChildren(currNode, context); + } + + this.canActivateChecks.push(new CanActivate(futurePath)); + // If we have a component, we need to go through an outlet. + if (future.component) { + this.setupChildRouteGuards(futureNode, null, context ? context.children : null, futurePath); + + // if we have a componentless route, we recurse but keep the same outlet map. + } else { + this.setupChildRouteGuards(futureNode, null, parentContexts, futurePath); + } + } + } + + private shouldRunGuardsAndResolvers( + curr: ActivatedRouteSnapshot, future: ActivatedRouteSnapshot, + mode: RunGuardsAndResolvers|undefined): boolean { + switch (mode) { + case 'always': + return true; + + case 'paramsOrQueryParamsChange': + return !equalParamsAndUrlSegments(curr, future) || + !shallowEqual(curr.queryParams, future.queryParams); + + case 'paramsChange': + default: + return !equalParamsAndUrlSegments(curr, future); + } + } + + private deactivateRouteAndItsChildren( + route: TreeNode, context: OutletContext|null): void { + const children = nodeChildrenAsMap(route); + const r = route.value; + + forEach(children, (node: TreeNode, childName: string) => { + if (!r.component) { + this.deactivateRouteAndItsChildren(node, context); + } else if (context) { + this.deactivateRouteAndItsChildren(node, context.children.getContext(childName)); + } else { + this.deactivateRouteAndItsChildren(node, null); + } + }); + + if (!r.component) { + this.canDeactivateChecks.push(new CanDeactivate(null, r)); + } else if (context && context.outlet && context.outlet.isActivated) { + this.canDeactivateChecks.push(new CanDeactivate(context.outlet.component, r)); + } else { + this.canDeactivateChecks.push(new CanDeactivate(null, r)); + } + } + + private runCanDeactivateChecks(): Observable { + const checks$ = from(this.canDeactivateChecks); + const runningChecks$ = mergeMap.call( + checks$, (check: CanDeactivate) => this.runCanDeactivate(check.component, check.route)); + return every.call(runningChecks$, (result: boolean) => result === true); + } + + private runCanActivateChecks(): Observable { + const checks$ = from(this.canActivateChecks); + const runningChecks$ = concatMap.call( + checks$, (check: CanActivate) => andObservables(from([ + this.fireChildActivationStart(check.path), this.runCanActivateChild(check.path), + this.runCanActivate(check.route) + ]))); + return every.call(runningChecks$, (result: boolean) => result === true); + // this.fireChildActivationStart(check.path), + } + + /** + * This should fire off `ChildActivationStart` events for each route being activated at this + * level. + * In other words, if you're activating `a` and `b` below, `path` will contain the + * `ActivatedRouteSnapshot`s for both and we will fire `ChildActivationStart` for both. Always + * return + * `true` so checks continue to run. + */ + private fireChildActivationStart(path: ActivatedRouteSnapshot[]): Observable { + if (!this.forwardEvent) return of (true); + const childActivations = path.slice(0, path.length - 1).reverse().filter(_ => _ !== null); + + return andObservables(map.call(from(childActivations), (snapshot: ActivatedRouteSnapshot) => { + if (this.forwardEvent && snapshot._routeConfig) { + this.forwardEvent(new ChildActivationStart(snapshot._routeConfig)); + } + return of (true); + })); + } + private runCanActivate(future: ActivatedRouteSnapshot): Observable { + const canActivate = future._routeConfig ? future._routeConfig.canActivate : null; + if (!canActivate || canActivate.length === 0) return of (true); + const obs = map.call(from(canActivate), (c: any) => { + const guard = this.getToken(c, future); + let observable: Observable; + if (guard.canActivate) { + observable = wrapIntoObservable(guard.canActivate(future, this.future)); + } else { + observable = wrapIntoObservable(guard(future, this.future)); + } + return first.call(observable); + }); + return andObservables(obs); + } + + private runCanActivateChild(path: ActivatedRouteSnapshot[]): Observable { + const future = path[path.length - 1]; + + const canActivateChildGuards = path.slice(0, path.length - 1) + .reverse() + .map(p => this.extractCanActivateChild(p)) + .filter(_ => _ !== null); + + return andObservables(map.call(from(canActivateChildGuards), (d: any) => { + const obs = map.call(from(d.guards), (c: any) => { + const guard = this.getToken(c, d.node); + let observable: Observable; + if (guard.canActivateChild) { + observable = wrapIntoObservable(guard.canActivateChild(future, this.future)); + } else { + observable = wrapIntoObservable(guard(future, this.future)); + } + return first.call(observable); + }); + return andObservables(obs); + })); + } + + private extractCanActivateChild(p: ActivatedRouteSnapshot): + {node: ActivatedRouteSnapshot, guards: any[]}|null { + const canActivateChild = p._routeConfig ? p._routeConfig.canActivateChild : null; + if (!canActivateChild || canActivateChild.length === 0) return null; + return {node: p, guards: canActivateChild}; + } + + private runCanDeactivate(component: Object|null, curr: ActivatedRouteSnapshot): + Observable { + const canDeactivate = curr && curr._routeConfig ? curr._routeConfig.canDeactivate : null; + if (!canDeactivate || canDeactivate.length === 0) return of (true); + const canDeactivate$ = mergeMap.call(from(canDeactivate), (c: any) => { + const guard = this.getToken(c, curr); + let observable: Observable; + if (guard.canDeactivate) { + observable = + wrapIntoObservable(guard.canDeactivate(component, curr, this.curr, this.future)); + } else { + observable = wrapIntoObservable(guard(component, curr, this.curr, this.future)); + } + return first.call(observable); + }); + return every.call(canDeactivate$, (result: any) => result === true); + } + + private runResolve(future: ActivatedRouteSnapshot): Observable { + const resolve = future._resolve; + return map.call(this.resolveNode(resolve, future), (resolvedData: any): any => { + future._resolvedData = resolvedData; + future.data = {...future.data, ...inheritedParamsDataResolve(future).resolve}; + return null; + }); + } + + private resolveNode(resolve: ResolveData, future: ActivatedRouteSnapshot): Observable { + const keys = Object.keys(resolve); + if (keys.length === 0) { + return of ({}); + } + if (keys.length === 1) { + const key = keys[0]; + return map.call( + this.getResolver(resolve[key], future), (value: any) => { return {[key]: value}; }); + } + const data: {[k: string]: any} = {}; + const runningResolvers$ = mergeMap.call(from(keys), (key: string) => { + return map.call(this.getResolver(resolve[key], future), (value: any) => { + data[key] = value; + return value; + }); + }); + return map.call(last.call(runningResolvers$), () => data); + } + + private getResolver(injectionToken: any, future: ActivatedRouteSnapshot): Observable { + const resolver = this.getToken(injectionToken, future); + return resolver.resolve ? wrapIntoObservable(resolver.resolve(future, this.future)) : + wrapIntoObservable(resolver(future, this.future)); + } + + private getToken(token: any, snapshot: ActivatedRouteSnapshot): any { + const config = closestLoadedConfig(snapshot); + const injector = config ? config.module.injector : this.moduleInjector; + return injector.get(token); + } +} + + +function closestLoadedConfig(snapshot: ActivatedRouteSnapshot): LoadedRouterConfig|null { + if (!snapshot) return null; + + for (let s = snapshot.parent; s; s = s.parent) { + const route = s._routeConfig; + if (route && route._loadedConfig) return route._loadedConfig; + } + + return null; +} diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index a3d1b65e43..bf24f9c410 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -12,31 +12,27 @@ import {BehaviorSubject} from 'rxjs/BehaviorSubject'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; -import {from} from 'rxjs/observable/from'; import {of } from 'rxjs/observable/of'; import {concatMap} from 'rxjs/operator/concatMap'; -import {every} from 'rxjs/operator/every'; -import {first} from 'rxjs/operator/first'; -import {last} from 'rxjs/operator/last'; import {map} from 'rxjs/operator/map'; import {mergeMap} from 'rxjs/operator/mergeMap'; -import {reduce} from 'rxjs/operator/reduce'; import {applyRedirects} from './apply_redirects'; -import {LoadedRouterConfig, QueryParamsHandling, ResolveData, Route, Routes, RunGuardsAndResolvers, validateConfig} from './config'; +import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, validateConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; -import {Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; +import {ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteEvent, RoutesRecognized} from './events'; +import {PreActivation} from './pre_activation'; import {recognize} from './recognize'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {RouterConfigLoader} from './router_config_loader'; -import {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; -import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState, equalParamsAndUrlSegments, inheritedParamsDataResolve} from './router_state'; +import {ChildrenOutletContexts} from './router_outlet_context'; +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 {andObservables, forEach, shallowEqual, waitForMap, wrapIntoObservable} from './utils/collection'; -import {TreeNode} from './utils/tree'; +import {forEach} from './utils/collection'; +import {TreeNode, nodeChildrenAsMap} from './utils/tree'; declare let Zone: any; @@ -626,18 +622,19 @@ export class Router { // run preactivation: guards and data resolvers let preActivation: PreActivation; - const preactivationTraverse$ = map.call( + const preactivationSetup$ = map.call( beforePreactivationDone$, ({appliedUrl, snapshot}: {appliedUrl: string, snapshot: RouterStateSnapshot}) => { const moduleInjector = this.ngModule.injector; - preActivation = - new PreActivation(snapshot, this.currentRouterState.snapshot, moduleInjector); - preActivation.traverse(this.rootContexts); + preActivation = new PreActivation( + snapshot, this.currentRouterState.snapshot, moduleInjector, + (evt: RouteEvent) => this.triggerEvent(evt)); + preActivation.initalize(this.rootContexts); return {appliedUrl, snapshot}; }); const preactivationCheckGuards$ = mergeMap.call( - preactivationTraverse$, + preactivationSetup$, ({appliedUrl, snapshot}: {appliedUrl: string, snapshot: RouterStateSnapshot}) => { if (this.navigationId !== id) return of (false); @@ -715,7 +712,8 @@ export class Router { } } - new ActivateRoutes(this.routeReuseStrategy, state, storedState) + new ActivateRoutes( + this.routeReuseStrategy, state, storedState, (evt: Event) => this.triggerEvent(evt)) .activate(this.rootContexts); navigationIsSuccessful = true; @@ -763,289 +761,10 @@ export class Router { } } - -class CanActivate { - constructor(public path: ActivatedRouteSnapshot[]) {} - get route(): ActivatedRouteSnapshot { return this.path[this.path.length - 1]; } -} - -class CanDeactivate { - constructor(public component: Object|null, public route: ActivatedRouteSnapshot) {} -} - -export class PreActivation { - private canActivateChecks: CanActivate[] = []; - private canDeactivateChecks: CanDeactivate[] = []; - - constructor( - private future: RouterStateSnapshot, private curr: RouterStateSnapshot, - private moduleInjector: Injector) {} - - traverse(parentContexts: ChildrenOutletContexts): void { - const futureRoot = this.future._root; - const currRoot = this.curr ? this.curr._root : null; - 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.isDeactivating() && !this.isActivating()) { - return of (true); - } - const canDeactivate$ = this.runCanDeactivateChecks(); - return mergeMap.call( - canDeactivate$, - (canDeactivate: boolean) => canDeactivate ? this.runCanActivateChecks() : of (false)); - } - - resolveData(): Observable { - 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 { - const prevChildren = nodeChildrenAsMap(currNode); - - // Process the children of the future route - futureNode.children.forEach(c => { - this.traverseRoutes(c, prevChildren[c.value.outlet], contexts, futurePath.concat([c.value])); - delete prevChildren[c.value.outlet]; - }); - - // Process any children left from the current route (not active for the future route) - forEach( - prevChildren, (v: TreeNode, k: string) => - this.deactivateRouteAndItsChildren(v, contexts !.getContext(k))); - } - - private traverseRoutes( - futureNode: TreeNode, currNode: TreeNode, - parentContexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { - const future = futureNode.value; - const curr = currNode ? currNode.value : null; - const context = parentContexts ? parentContexts.getContext(futureNode.value.outlet) : null; - - // reusing the node - if (curr && future._routeConfig === curr._routeConfig) { - const shouldRunGuardsAndResolvers = this.shouldRunGuardsAndResolvers( - curr, future, future._routeConfig !.runGuardsAndResolvers); - if (shouldRunGuardsAndResolvers) { - this.canActivateChecks.push(new CanActivate(futurePath)); - } else { - // we need to set the data - future.data = curr.data; - future._resolvedData = curr._resolvedData; - } - - // If we have a component, we need to go through an outlet. - if (future.component) { - this.traverseChildRoutes( - futureNode, currNode, context ? context.children : null, futurePath); - - // if we have a componentless route, we recurse but keep the same outlet map. - } else { - this.traverseChildRoutes(futureNode, currNode, parentContexts, futurePath); - } - - if (shouldRunGuardsAndResolvers) { - const outlet = context !.outlet !; - this.canDeactivateChecks.push(new CanDeactivate(outlet.component, curr)); - } - } else { - if (curr) { - this.deactivateRouteAndItsChildren(currNode, context); - } - - this.canActivateChecks.push(new CanActivate(futurePath)); - // If we have a component, we need to go through an outlet. - if (future.component) { - this.traverseChildRoutes(futureNode, null, context ? context.children : null, futurePath); - - // if we have a componentless route, we recurse but keep the same outlet map. - } else { - this.traverseChildRoutes(futureNode, null, parentContexts, futurePath); - } - } - } - - private shouldRunGuardsAndResolvers( - curr: ActivatedRouteSnapshot, future: ActivatedRouteSnapshot, - mode: RunGuardsAndResolvers|undefined): boolean { - switch (mode) { - case 'always': - return true; - - case 'paramsOrQueryParamsChange': - return !equalParamsAndUrlSegments(curr, future) || - !shallowEqual(curr.queryParams, future.queryParams); - - case 'paramsChange': - default: - return !equalParamsAndUrlSegments(curr, future); - } - } - - private deactivateRouteAndItsChildren( - route: TreeNode, context: OutletContext|null): void { - const children = nodeChildrenAsMap(route); - const r = route.value; - - forEach(children, (node: TreeNode, childName: string) => { - if (!r.component) { - this.deactivateRouteAndItsChildren(node, context); - } else if (context) { - this.deactivateRouteAndItsChildren(node, context.children.getContext(childName)); - } else { - this.deactivateRouteAndItsChildren(node, null); - } - }); - - if (!r.component) { - this.canDeactivateChecks.push(new CanDeactivate(null, r)); - } else if (context && context.outlet && context.outlet.isActivated) { - this.canDeactivateChecks.push(new CanDeactivate(context.outlet.component, r)); - } else { - this.canDeactivateChecks.push(new CanDeactivate(null, r)); - } - } - - private runCanDeactivateChecks(): Observable { - const checks$ = from(this.canDeactivateChecks); - const runningChecks$ = mergeMap.call( - checks$, (check: CanDeactivate) => this.runCanDeactivate(check.component, check.route)); - return every.call(runningChecks$, (result: boolean) => result === true); - } - - private runCanActivateChecks(): Observable { - const checks$ = from(this.canActivateChecks); - const runningChecks$ = concatMap.call( - checks$, (check: CanActivate) => andObservables(from( - [this.runCanActivateChild(check.path), this.runCanActivate(check.route)]))); - return every.call(runningChecks$, (result: boolean) => result === true); - } - - private runCanActivate(future: ActivatedRouteSnapshot): Observable { - const canActivate = future._routeConfig ? future._routeConfig.canActivate : null; - if (!canActivate || canActivate.length === 0) return of (true); - const obs = map.call(from(canActivate), (c: any) => { - const guard = this.getToken(c, future); - let observable: Observable; - if (guard.canActivate) { - observable = wrapIntoObservable(guard.canActivate(future, this.future)); - } else { - observable = wrapIntoObservable(guard(future, this.future)); - } - return first.call(observable); - }); - return andObservables(obs); - } - - private runCanActivateChild(path: ActivatedRouteSnapshot[]): Observable { - const future = path[path.length - 1]; - - const canActivateChildGuards = path.slice(0, path.length - 1) - .reverse() - .map(p => this.extractCanActivateChild(p)) - .filter(_ => _ !== null); - - return andObservables(map.call(from(canActivateChildGuards), (d: any) => { - const obs = map.call(from(d.guards), (c: any) => { - const guard = this.getToken(c, d.node); - let observable: Observable; - if (guard.canActivateChild) { - observable = wrapIntoObservable(guard.canActivateChild(future, this.future)); - } else { - observable = wrapIntoObservable(guard(future, this.future)); - } - return first.call(observable); - }); - return andObservables(obs); - })); - } - - private extractCanActivateChild(p: ActivatedRouteSnapshot): - {node: ActivatedRouteSnapshot, guards: any[]}|null { - const canActivateChild = p._routeConfig ? p._routeConfig.canActivateChild : null; - if (!canActivateChild || canActivateChild.length === 0) return null; - return {node: p, guards: canActivateChild}; - } - - private runCanDeactivate(component: Object|null, curr: ActivatedRouteSnapshot): - Observable { - const canDeactivate = curr && curr._routeConfig ? curr._routeConfig.canDeactivate : null; - if (!canDeactivate || canDeactivate.length === 0) return of (true); - const canDeactivate$ = mergeMap.call(from(canDeactivate), (c: any) => { - const guard = this.getToken(c, curr); - let observable: Observable; - if (guard.canDeactivate) { - observable = - wrapIntoObservable(guard.canDeactivate(component, curr, this.curr, this.future)); - } else { - observable = wrapIntoObservable(guard(component, curr, this.curr, this.future)); - } - return first.call(observable); - }); - return every.call(canDeactivate$, (result: any) => result === true); - } - - private runResolve(future: ActivatedRouteSnapshot): Observable { - const resolve = future._resolve; - return map.call(this.resolveNode(resolve, future), (resolvedData: any): any => { - future._resolvedData = resolvedData; - future.data = {...future.data, ...inheritedParamsDataResolve(future).resolve}; - return null; - }); - } - - private resolveNode(resolve: ResolveData, future: ActivatedRouteSnapshot): Observable { - const keys = Object.keys(resolve); - if (keys.length === 0) { - return of ({}); - } - if (keys.length === 1) { - const key = keys[0]; - return map.call( - this.getResolver(resolve[key], future), (value: any) => { return {[key]: value}; }); - } - const data: {[k: string]: any} = {}; - const runningResolvers$ = mergeMap.call(from(keys), (key: string) => { - return map.call(this.getResolver(resolve[key], future), (value: any) => { - data[key] = value; - return value; - }); - }); - return map.call(last.call(runningResolvers$), () => data); - } - - private getResolver(injectionToken: any, future: ActivatedRouteSnapshot): Observable { - const resolver = this.getToken(injectionToken, future); - return resolver.resolve ? wrapIntoObservable(resolver.resolve(future, this.future)) : - wrapIntoObservable(resolver(future, this.future)); - } - - private getToken(token: any, snapshot: ActivatedRouteSnapshot): any { - const config = closestLoadedConfig(snapshot); - const injector = config ? config.module.injector : this.moduleInjector; - return injector.get(token); - } -} - class ActivateRoutes { constructor( private routeReuseStrategy: RouteReuseStrategy, private futureState: RouterState, - private currState: RouterState) {} + private currState: RouterState, private forwardEvent: (evt: RouteEvent) => void) {} activate(parentContexts: ChildrenOutletContexts): void { const futureRoot = this.futureState._root; @@ -1145,6 +864,9 @@ class ActivateRoutes { const children: {[outlet: string]: any} = nodeChildrenAsMap(currNode); futureNode.children.forEach( c => { this.activateRoutes(c, children[c.value.outlet], contexts); }); + if (futureNode.children.length && futureNode.value.routeConfig) { + this.forwardEvent(new ChildActivationEnd(futureNode.value.routeConfig)); + } } private activateRoutes( @@ -1220,28 +942,6 @@ function parentLoadedConfig(snapshot: ActivatedRouteSnapshot): LoadedRouterConfi return null; } -function closestLoadedConfig(snapshot: ActivatedRouteSnapshot): LoadedRouterConfig|null { - if (!snapshot) return null; - - for (let s = snapshot.parent; s; s = s.parent) { - const route = s._routeConfig; - if (route && route._loadedConfig) return route._loadedConfig; - } - - return null; -} - -// Return the list of T indexed by outlet name -function nodeChildrenAsMap(node: TreeNode| null) { - const map: {[outlet: string]: TreeNode} = {}; - - if (node) { - node.children.forEach(child => map[child.value.outlet] = child); - } - - return map; -} - function validateCommands(commands: string[]): void { for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; diff --git a/packages/router/src/shared.ts b/packages/router/src/shared.ts index 56814d5edd..d8da4a68d2 100644 --- a/packages/router/src/shared.ts +++ b/packages/router/src/shared.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.io/license */ - import {Route, UrlMatchResult} from './config'; import {UrlSegment, UrlSegmentGroup} from './url_tree'; diff --git a/packages/router/src/utils/collection.ts b/packages/router/src/utils/collection.ts index b96cadb1de..a256f2b64f 100644 --- a/packages/router/src/utils/collection.ts +++ b/packages/router/src/utils/collection.ts @@ -41,14 +41,23 @@ export function shallowEqual(a: {[x: string]: any}, b: {[x: string]: any}): bool return true; } +/** + * Flattens single-level nested arrays. + */ export function flatten(arr: T[][]): T[] { return Array.prototype.concat.apply([], arr); } +/** + * Return the last element of an array. + */ export function last(a: T[]): T|null { return a.length > 0 ? a[a.length - 1] : null; } +/** + * Verifys all booleans in an array are `true`. + */ export function and(bools: boolean[]): boolean { return !bools.some(v => !v); } @@ -85,6 +94,10 @@ export function waitForMap( return map.call(last$, () => res); } +/** + * ANDs Observables by merging all input observables, reducing to an Observable verifying all + * input Observables return `true`. + */ export function andObservables(observables: Observable>): Observable { const merged$ = mergeAll.call(observables); return every.call(merged$, (result: any) => result === true); diff --git a/packages/router/src/utils/tree.ts b/packages/router/src/utils/tree.ts index bcb6037e62..a811872a68 100644 --- a/packages/router/src/utils/tree.ts +++ b/packages/router/src/utils/tree.ts @@ -87,4 +87,15 @@ export class TreeNode { constructor(public value: T, public children: TreeNode[]) {} toString(): string { return `TreeNode(${this.value})`; } +} + +// Return the list of T indexed by outlet name +export function nodeChildrenAsMap(node: TreeNode| null) { + const map: {[outlet: string]: TreeNode} = {}; + + if (node) { + node.children.forEach(child => map[child.value.outlet] = child); + } + + return map; } \ No newline at end of file diff --git a/packages/router/test/helpers.ts b/packages/router/test/helpers.ts new file mode 100644 index 0000000000..2ef3b7e141 --- /dev/null +++ b/packages/router/test/helpers.ts @@ -0,0 +1,50 @@ +/** + * @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 {Type} from '@angular/core'; + +import {Data, ResolveData, Route} from '../src/config'; +import {ActivatedRouteSnapshot} from '../src/router_state'; +import {PRIMARY_OUTLET, ParamMap, Params, convertToParamMap} from '../src/shared'; +import {UrlSegment, UrlSegmentGroup, UrlTree, equalSegments} from '../src/url_tree'; + +export class Logger { + logs: string[] = []; + add(thing: string) { this.logs.push(thing); } + empty() { this.logs.length = 0; } +} + +export function provideTokenLogger(token: string, returnValue = true) { + return { + provide: token, + useFactory: (logger: Logger) => () => (logger.add(token), returnValue), + deps: [Logger] + }; +}; + +export declare type ARSArgs = { + url?: UrlSegment[], + params?: Params, + queryParams?: Params, + fragment?: string, + data?: Data, + outlet?: string, + component: Type| string | null, + routeConfig?: Route | null, + urlSegment?: UrlSegmentGroup, + lastPathIndex?: number, + resolve?: ResolveData +}; + +export function createActivatedRouteSnapshot(args: ARSArgs): ActivatedRouteSnapshot { + return new ActivatedRouteSnapshot( + args.url || [], args.params || {}, args.queryParams || null, + args.fragment || null, args.data || null, args.outlet || null, + args.component, args.routeConfig || {}, args.urlSegment || null, + args.lastPathIndex || -1, args.resolve || {}); +} diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index a9b3dba3f2..ebccb7bf9f 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, 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 {ActivatedRoute, ActivatedRouteSnapshot, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteEvent, RouteReuseStrategy, Router, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlTree} from '@angular/router'; import {Observable} from 'rxjs/Observable'; import {Observer} from 'rxjs/Observer'; import {of } from 'rxjs/observable/of'; @@ -428,7 +428,7 @@ describe('Integration', () => { }]); const recordedEvents: any[] = []; - router.events.forEach(e => recordedEvents.push(e)); + router.events.forEach(e => e instanceof RouteEvent || recordedEvents.push(e)); router.navigateByUrl('/team/22/user/victor'); advance(fixture); @@ -986,7 +986,7 @@ describe('Integration', () => { [{path: 'simple', component: SimpleCmp, resolve: {error: 'resolveError'}}]); const recordedEvents: any[] = []; - router.events.subscribe(e => recordedEvents.push(e)); + router.events.subscribe(e => e instanceof RouteEvent || recordedEvents.push(e)); let e: any = null; router.navigateByUrl('/simple') !.catch(error => e = error); @@ -2388,9 +2388,11 @@ describe('Integration', () => { [RouteConfigLoadEnd], [RoutesRecognized, '/lazyTrue/loaded'], [GuardsCheckStart, '/lazyTrue/loaded'], + [ChildActivationStart], [GuardsCheckEnd, '/lazyTrue/loaded'], [ResolveStart, '/lazyTrue/loaded'], [ResolveEnd, '/lazyTrue/loaded'], + [ChildActivationEnd], [NavigationEnd, '/lazyTrue/loaded'], ]); }))); @@ -3342,7 +3344,7 @@ describe('Integration', () => { }]); const events: any[] = []; - router.events.subscribe(e => events.push(e)); + router.events.subscribe(e => e instanceof RouteEvent || events.push(e)); // supported URL router.navigateByUrl('/include/user/kate'); @@ -3406,7 +3408,7 @@ describe('Integration', () => { }]); const events: any[] = []; - router.events.subscribe(e => events.push(e)); + router.events.subscribe(e => e instanceof RouteEvent || events.push(e)); location.go('/include/user/kate(aux:excluded)'); advance(fixture); diff --git a/packages/router/test/router.spec.ts b/packages/router/test/router.spec.ts index 7ea6d6bac9..be1e469c6d 100644 --- a/packages/router/test/router.spec.ts +++ b/packages/router/test/router.spec.ts @@ -10,13 +10,16 @@ import {Location} from '@angular/common'; import {TestBed, inject} from '@angular/core/testing'; import {ResolveData} from '../src/config'; -import {PreActivation, Router} from '../src/router'; +import {PreActivation} from '../src/pre_activation'; +import {Router} from '../src/router'; import {ChildrenOutletContexts} from '../src/router_outlet_context'; import {ActivatedRouteSnapshot, RouterStateSnapshot, createEmptyStateSnapshot} from '../src/router_state'; import {DefaultUrlSerializer} from '../src/url_tree'; import {TreeNode} from '../src/utils/tree'; import {RouterTestingModule} from '../testing/src/router_testing_module'; +import {Logger, createActivatedRouteSnapshot, provideTokenLogger} from './helpers'; + describe('Router', () => { describe('resetRootComponentType', () => { class NewRootComponent {} @@ -56,51 +59,295 @@ describe('Router', () => { const serializer = new DefaultUrlSerializer(); const inj = {get: (token: any) => () => `${token}_value`}; let empty: RouterStateSnapshot; + let logger: Logger; - beforeEach(() => { empty = createEmptyStateSnapshot(serializer.parse('/'), null !); }); + const CA_CHILD = 'canActivate_child'; + const CA_CHILD_FALSE = 'canActivate_child_false'; + const CAC_CHILD = 'canActivateChild_child'; + const CAC_CHILD_FALSE = 'canActivateChild_child_false'; + const CA_GRANDCHILD = 'canActivate_grandchild'; + const CA_GRANDCHILD_FALSE = 'canActivate_grandchild_false'; + const CDA_CHILD = 'canDeactivate_child'; + const CDA_CHILD_FALSE = 'canDeactivate_child_false'; + const CDA_GRANDCHILD = 'canDeactivate_grandchild'; + const CDA_GRANDCHILD_FALSE = 'canDeactivate_grandchild_false'; - it('should resolve data', () => { - const r = {data: 'resolver'}; - const n = createActivatedRouteSnapshot('a', {resolve: r}); - const s = new RouterStateSnapshot('url', new TreeNode(empty.root, [new TreeNode(n, [])])); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + Logger, provideTokenLogger(CA_CHILD), provideTokenLogger(CA_CHILD_FALSE, false), + provideTokenLogger(CAC_CHILD), provideTokenLogger(CAC_CHILD_FALSE, false), + provideTokenLogger(CA_GRANDCHILD), provideTokenLogger(CA_GRANDCHILD_FALSE, false), + provideTokenLogger(CDA_CHILD), provideTokenLogger(CDA_CHILD_FALSE, false), + provideTokenLogger(CDA_GRANDCHILD), provideTokenLogger(CDA_GRANDCHILD_FALSE, false) + ] + }); - checkResolveData(s, empty, inj, () => { - expect(s.root.firstChild !.data).toEqual({data: 'resolver_value'}); + }); + + beforeEach(inject([Logger], (_logger: Logger) => { + empty = createEmptyStateSnapshot(serializer.parse('/'), null !); + logger = _logger; + })); + + describe('guards', () => { + it('should run CanActivate checks', () => { + /** + * R --> R + * \ + * child (CA, CAC) + * \ + * grandchild (CA) + */ + + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: { + + canActivate: [CA_CHILD], + canActivateChild: [CAC_CHILD] + } + }); + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, empty, TestBed, (result) => { + expect(result).toBe(true); + expect(logger.logs).toEqual([CA_CHILD, CAC_CHILD, CA_GRANDCHILD]); + }); + }); + + it('should not run grandchild guards if child fails', () => { + /** + * R --> R + * \ + * child (CA: x, CAC) + * \ + * grandchild (CA) + */ + + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: {canActivate: [CA_CHILD_FALSE], canActivateChild: [CAC_CHILD]} + }); + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, empty, TestBed, (result) => { + expect(result).toBe(false); + expect(logger.logs).toEqual([CA_CHILD_FALSE]); + }); + }); + + it('should not run grandchild guards if child canActivateChild fails', () => { + /** + * R --> R + * \ + * child (CA, CAC: x) + * \ + * grandchild (CA) + */ + + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: {canActivate: [CA_CHILD], canActivateChild: [CAC_CHILD_FALSE]} + }); + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, empty, TestBed, (result) => { + expect(result).toBe(false); + expect(logger.logs).toEqual([CA_CHILD, CAC_CHILD_FALSE]); + }); + }); + + it('should run deactivate guards before activate guards', () => { + /** + * R --> R + * / \ + * prev (CDA) child (CA) + * \ + * grandchild (CA) + */ + + const prevSnapshot = createActivatedRouteSnapshot( + {component: 'prev', routeConfig: {canDeactivate: [CDA_CHILD]}}); + + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: {canActivate: [CA_CHILD], canActivateChild: [CAC_CHILD]} + }); + + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const currentState = new RouterStateSnapshot( + 'prev', new TreeNode(empty.root, [new TreeNode(prevSnapshot, [])])); + + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, currentState, TestBed, (result) => { + expect(logger.logs).toEqual([CDA_CHILD, CA_CHILD, CAC_CHILD, CA_GRANDCHILD]); + }); + }); + + it('should not run activate if deactivate fails guards', () => { + /** + * R --> R + * / \ + * prev (CDA) child (CA) + * \ + * grandchild (CA) + */ + + const prevSnapshot = createActivatedRouteSnapshot( + {component: 'prev', routeConfig: {canDeactivate: [CDA_CHILD_FALSE]}}); + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: {canActivate: [CA_CHILD], canActivateChild: [CAC_CHILD]} + }); + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const currentState = new RouterStateSnapshot( + 'prev', new TreeNode(empty.root, [new TreeNode(prevSnapshot, [])])); + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, currentState, TestBed, (result) => { + expect(result).toBe(false); + expect(logger.logs).toEqual([CDA_CHILD_FALSE]); + }); + }); + it('should deactivate from bottom up, then activate top down', () => { + /** + * R --> R + * / \ + * prevChild (CDA) child (CA) + * / \ + * prevGrandchild(CDA) grandchild (CA) + */ + + const prevChildSnapshot = createActivatedRouteSnapshot( + {component: 'prev_child', routeConfig: {canDeactivate: [CDA_CHILD]}}); + const prevGrandchildSnapshot = createActivatedRouteSnapshot( + {component: 'prev_grandchild', routeConfig: {canDeactivate: [CDA_GRANDCHILD]}}); + const childSnapshot = createActivatedRouteSnapshot({ + component: 'child', + routeConfig: {canActivate: [CA_CHILD], canActivateChild: [CAC_CHILD]} + }); + const grandchildSnapshot = createActivatedRouteSnapshot( + {component: 'grandchild', routeConfig: {canActivate: [CA_GRANDCHILD]}}); + + const currentState = new RouterStateSnapshot( + 'prev', new TreeNode(empty.root, [ + new TreeNode(prevChildSnapshot, [new TreeNode(prevGrandchildSnapshot, [])]) + ])); + + const futureState = new RouterStateSnapshot( + 'url', + new TreeNode( + empty.root, [new TreeNode(childSnapshot, [new TreeNode(grandchildSnapshot, [])])])); + + checkGuards(futureState, currentState, TestBed, (result) => { + expect(result).toBe(true); + expect(logger.logs).toEqual([ + CDA_GRANDCHILD, CDA_CHILD, CA_CHILD, CAC_CHILD, CA_GRANDCHILD + ]); + }); + + logger.empty(); + checkGuards(currentState, futureState, TestBed, (result) => { + expect(result).toBe(true); + expect(logger.logs).toEqual([]); + }); }); }); - it('should wait for the parent resolve to complete', () => { - const parentResolve = {data: 'resolver'}; - const childResolve = {}; + describe('resolve', () => { - const parent = createActivatedRouteSnapshot(null !, {resolve: parentResolve}); - const child = createActivatedRouteSnapshot('b', {resolve: childResolve}); + it('should resolve data', () => { + /** + * R --> R + * \ + * a + */ + const r = {data: 'resolver'}; + const n = createActivatedRouteSnapshot({component: 'a', resolve: r}); + const s = new RouterStateSnapshot('url', new TreeNode(empty.root, [new TreeNode(n, [])])); - const s = new RouterStateSnapshot( - 'url', new TreeNode(empty.root, [new TreeNode(parent, [new TreeNode(child, [])])])); - - const inj = {get: (token: any) => () => Promise.resolve(`${token}_value`)}; - - checkResolveData(s, empty, inj, () => { - expect(s.root.firstChild !.firstChild !.data).toEqual({data: 'resolver_value'}); + checkResolveData(s, empty, inj, () => { + expect(s.root.firstChild !.data).toEqual({data: 'resolver_value'}); + }); }); - }); - it('should copy over data when creating a snapshot', () => { - const r1 = {data: 'resolver1'}; - const r2 = {data: 'resolver2'}; + it('should wait for the parent resolve to complete', () => { + /** + * R --> R + * \ + * null (resolve: parentResolve) + * \ + * b (resolve: childResolve) + */ + const parentResolve = {data: 'resolver'}; + const childResolve = {}; - const n1 = createActivatedRouteSnapshot('a', {resolve: r1}); - const s1 = new RouterStateSnapshot('url', new TreeNode(empty.root, [new TreeNode(n1, [])])); - checkResolveData(s1, empty, inj, () => {}); + const parent = createActivatedRouteSnapshot({component: null !, resolve: parentResolve}); + const child = createActivatedRouteSnapshot({component: 'b', resolve: childResolve}); - const n21 = createActivatedRouteSnapshot('a', {resolve: r1}); - const n22 = createActivatedRouteSnapshot('b', {resolve: r2}); - const s2 = new RouterStateSnapshot( - 'url', new TreeNode(empty.root, [new TreeNode(n21, [new TreeNode(n22, [])])])); - checkResolveData(s2, s1, inj, () => { - expect(s2.root.firstChild !.data).toEqual({data: 'resolver1_value'}); - expect(s2.root.firstChild !.firstChild !.data).toEqual({data: 'resolver2_value'}); + const s = new RouterStateSnapshot( + 'url', new TreeNode(empty.root, [new TreeNode(parent, [new TreeNode(child, [])])])); + + const inj = {get: (token: any) => () => Promise.resolve(`${token}_value`)}; + + checkResolveData(s, empty, inj, () => { + expect(s.root.firstChild !.firstChild !.data).toEqual({data: 'resolver_value'}); + }); + }); + + it('should copy over data when creating a snapshot', () => { + /** + * R --> R --> R + * \ \ + * n1 (resolve: r1) n21 (resolve: r1) + * \ + * n22 (resolve: r2) + */ + const r1 = {data: 'resolver1'}; + const r2 = {data: 'resolver2'}; + + const n1 = createActivatedRouteSnapshot({component: 'a', resolve: r1}); + const s1 = new RouterStateSnapshot('url', new TreeNode(empty.root, [new TreeNode(n1, [])])); + checkResolveData(s1, empty, inj, () => {}); + + const n21 = createActivatedRouteSnapshot({component: 'a', resolve: r1}); + const n22 = createActivatedRouteSnapshot({component: 'b', resolve: r2}); + const s2 = new RouterStateSnapshot( + 'url', new TreeNode(empty.root, [new TreeNode(n21, [new TreeNode(n22, [])])])); + checkResolveData(s2, s1, inj, () => { + expect(s2.root.firstChild !.data).toEqual({data: 'resolver1_value'}); + expect(s2.root.firstChild !.firstChild !.data).toEqual({data: 'resolver2_value'}); + }); }); }); }); @@ -109,12 +356,14 @@ describe('Router', () => { function checkResolveData( future: RouterStateSnapshot, curr: RouterStateSnapshot, injector: any, check: any): void { const p = new PreActivation(future, curr, injector); - p.traverse(new ChildrenOutletContexts()); + p.initalize(new ChildrenOutletContexts()); p.resolveData().subscribe(check, (e) => { throw e; }); } -function createActivatedRouteSnapshot(cmp: string, extra: any = {}): ActivatedRouteSnapshot { - return new ActivatedRouteSnapshot( - [], {}, null, null, null, null, cmp, {}, null, -1, - extra.resolve); +function checkGuards( + future: RouterStateSnapshot, curr: RouterStateSnapshot, injector: any, + check: (result: boolean) => void): void { + const p = new PreActivation(future, curr, injector); + p.initalize(new ChildrenOutletContexts()); + p.checkGuards().subscribe(check, (e) => { throw e; }); } diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index d234e646c9..471c6e849e 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -59,6 +59,16 @@ export interface CanLoad { canLoad(route: Route): Observable | Promise | boolean; } +/** @experimental */ +export declare class ChildActivationEnd extends RouteEvent { + toString(): string; +} + +/** @experimental */ +export declare class ChildActivationStart extends RouteEvent { + toString(): string; +} + /** @stable */ export declare class ChildrenOutletContexts { getContext(childName: string): OutletContext | null; @@ -87,7 +97,7 @@ export declare class DefaultUrlSerializer implements UrlSerializer { export declare type DetachedRouteHandle = {}; /** @stable */ -export declare type Event = NavigationStart | NavigationEnd | NavigationCancel | NavigationError | RoutesRecognized | RouteConfigLoadStart | RouteConfigLoadEnd | GuardsCheckStart | GuardsCheckEnd | ResolveStart | ResolveEnd; +export declare type Event = RouterEvent | RouteEvent; /** @stable */ export interface ExtraOptions { @@ -99,11 +109,9 @@ export interface ExtraOptions { } /** @experimental */ -export declare class GuardsCheckEnd { - id: number; +export declare class GuardsCheckEnd extends RouterEvent { shouldActivate: boolean; state: RouterStateSnapshot; - url: string; urlAfterRedirects: string; constructor( id: number, @@ -115,10 +123,8 @@ export declare class GuardsCheckEnd { } /** @experimental */ -export declare class GuardsCheckStart { - id: number; +export declare class GuardsCheckStart extends RouterEvent { state: RouterStateSnapshot; - url: string; urlAfterRedirects: string; constructor( id: number, @@ -135,10 +141,8 @@ export declare type LoadChildren = string | LoadChildrenCallback; export declare type LoadChildrenCallback = () => Type | NgModuleFactory | Promise> | Observable>; /** @stable */ -export declare class NavigationCancel { - id: number; +export declare class NavigationCancel extends RouterEvent { reason: string; - url: string; constructor( id: number, url: string, @@ -147,9 +151,7 @@ export declare class NavigationCancel { } /** @stable */ -export declare class NavigationEnd { - id: number; - url: string; +export declare class NavigationEnd extends RouterEvent { urlAfterRedirects: string; constructor( id: number, @@ -159,10 +161,8 @@ export declare class NavigationEnd { } /** @stable */ -export declare class NavigationError { +export declare class NavigationError extends RouterEvent { error: any; - id: number; - url: string; constructor( id: number, url: string, @@ -183,12 +183,7 @@ export interface NavigationExtras { } /** @stable */ -export declare class NavigationStart { - id: number; - url: string; - constructor( - id: number, - url: string); +export declare class NavigationStart extends RouterEvent { toString(): string; } @@ -246,10 +241,8 @@ export declare type ResolveData = { }; /** @experimental */ -export declare class ResolveEnd { - id: number; +export declare class ResolveEnd extends RouterEvent { state: RouterStateSnapshot; - url: string; urlAfterRedirects: string; constructor( id: number, @@ -260,10 +253,8 @@ export declare class ResolveEnd { } /** @experimental */ -export declare class ResolveStart { - id: number; +export declare class ResolveStart extends RouterEvent { state: RouterStateSnapshot; - url: string; urlAfterRedirects: string; constructor( id: number, @@ -293,19 +284,22 @@ export interface Route { } /** @experimental */ -export declare class RouteConfigLoadEnd { - route: Route; - constructor(route: Route); +export declare class RouteConfigLoadEnd extends RouteEvent { toString(): string; } /** @experimental */ -export declare class RouteConfigLoadStart { - route: Route; - constructor(route: Route); +export declare class RouteConfigLoadStart extends RouteEvent { toString(): string; } +/** @experimental */ +export declare class RouteEvent { + route: Route; + constructor( + route: Route); +} + /** @stable */ export declare class Router { config: Routes; @@ -453,10 +447,8 @@ export declare type Routes = Route[]; export declare const ROUTES: InjectionToken; /** @stable */ -export declare class RoutesRecognized { - id: number; +export declare class RoutesRecognized extends RouterEvent { state: RouterStateSnapshot; - url: string; urlAfterRedirects: string; constructor( id: number,