From e792f2c76a5ef03c9a3e548b309cee364dd0138f Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Mon, 24 Sep 2018 13:52:03 -0400 Subject: [PATCH] Generics --- lib/router/index.ts | 4 +- lib/router/route-info.ts | 127 +- lib/router/router.ts | 1299 ++++++++--------- lib/router/transition-intent.ts | 18 +- .../named-transition-intent.ts | 23 +- .../url-transition-intent.ts | 32 +- lib/router/transition-state.ts | 14 +- lib/router/transition.ts | 82 +- lib/router/utils.ts | 2 +- lib/rsvp/index.d.ts | 6 +- tests/async_get_handler_test.ts | 6 +- tests/handler_info_test.ts | 13 +- tests/query_params_test.ts | 13 +- tests/router_test.ts | 67 +- tests/test_helpers.ts | 41 +- tests/transition_intent_test.ts | 32 +- tests/transition_state_test.ts | 5 +- 17 files changed, 898 insertions(+), 886 deletions(-) diff --git a/lib/router/index.ts b/lib/router/index.ts index ac4ac700..63606b92 100644 --- a/lib/router/index.ts +++ b/lib/router/index.ts @@ -1,4 +1,4 @@ export { default } from './router'; -export { Transition } from './transition'; +export { default as InternalTransition, PublicTransition as Transition } from './transition'; export { default as TransitionState } from './transition-state'; -export { Route } from './route-info'; +export { default as InternalRouteInfo, RouteInfo, Route } from './route-info'; diff --git a/lib/router/route-info.ts b/lib/router/route-info.ts index b4665399..49d8dcaf 100644 --- a/lib/router/route-info.ts +++ b/lib/router/route-info.ts @@ -1,62 +1,59 @@ import { Promise } from 'rsvp'; -import { Dict, Maybe } from './core'; +import { Dict, Maybe, Option } from './core'; import Router, { SerializerFunc } from './router'; -import { isTransition, prepareResult, Transition } from './transition'; +import InternalTransition, { + isTransition, + prepareResult, + PublicTransition as Transition, +} from './transition'; import { isParam, isPromise, merge } from './utils'; interface IModel { id?: string | number; } -export interface RouteHooks { +export interface Route { + inaccessibleByURL?: boolean; + routeName: string; + context: unknown; + events?: Dict; model?( params: Dict, transition: Transition ): Promise | null | undefined> | undefined | Dict; deserialize?(params: Dict, transition: Transition): Dict; - serialize?(model: Dict, params: string[]): Dict; - beforeModel?(transition: Transition): Promise | null | undefined> | undefined; - afterModel?( - resolvedModel: Dict, - transition: Transition - ): Promise | null | undefined>; + serialize?(model: {}, params: string[]): {} | undefined; + beforeModel?(transition: Transition): Promise | any; + afterModel?(resolvedModel: any, transition: Transition): Promise | any; setup?(context: Dict, transition: Transition): void; enter?(transition: Transition): void; exit?(transition?: Transition): void; reset?(wasReset: boolean, transition?: Transition): void; contextDidChange?(): void; - // Underscore methods for some reason redirect?(context: Dict, transition: Transition): void; } -export interface Route extends RouteHooks { - inaccessibleByURL?: boolean; - routeName: string; - context: unknown; - events?: Dict; -} - export type Continuation = () => PromiseLike | boolean; -export interface IRouteInfo { +export interface RouteInfo { readonly name: string; - readonly parent: Maybe; - readonly child: Maybe; + readonly parent: Maybe; + readonly child: Maybe; readonly localName: string; readonly params: Dict; find( - predicate: (this: void, routeInfo: IRouteInfo, i: number) => boolean, + predicate: (this: void, routeInfo: RouteInfo, i: number) => boolean, thisArg: any - ): IRouteInfo | undefined; + ): RouteInfo | undefined; } -let ROUTE_INFO_LINKS = new WeakMap(); +let ROUTE_INFO_LINKS = new WeakMap, RouteInfo>(); -export function toReadOnlyRouteInfo(routeInfos: PrivateRouteInfo[]) { +export function toReadOnlyRouteInfo(routeInfos: InternalRouteInfo[]) { return routeInfos.map((info, i) => { let { name, params, queryParams, paramNames } = info; - let publicRouteInfo = new class RouteInfo implements IRouteInfo { - find(predicate: (this: void, routeInfo: IRouteInfo, i: number) => boolean, thisArg: any) { + let publicRouteInfo = new class implements RouteInfo { + find(predicate: (this: void, routeInfo: RouteInfo, i: number) => boolean, thisArg: any) { let routeInfo; let publicInfo; for (let i = 0; routeInfos.length > 0; i++) { @@ -108,10 +105,10 @@ export function toReadOnlyRouteInfo(routeInfos: PrivateRouteInfo[]) { }); } -export default class PrivateRouteInfo { - private _routePromise?: Promise = undefined; - private _route?: Route = undefined; - protected router: Router; +export default class InternalRouteInfo { + private _routePromise?: Promise = undefined; + private _route?: Option = null; + protected router: Router; paramNames: string[]; name: string; params: Dict = {}; @@ -119,7 +116,7 @@ export default class PrivateRouteInfo { context?: Dict; isResolved = false; - constructor(router: Router, name: string, paramNames: string[], route?: Route) { + constructor(router: Router, name: string, paramNames: string[], route?: T) { this.name = name; this.paramNames = paramNames; this.router = router; @@ -128,7 +125,7 @@ export default class PrivateRouteInfo { } } - getModel(_transition: Transition) { + getModel(_transition: InternalTransition) { return Promise.resolve(this.context); } @@ -136,7 +133,10 @@ export default class PrivateRouteInfo { return this.params || {}; } - resolve(shouldContinue: Continuation, transition: Transition): Promise { + resolve( + shouldContinue: Continuation, + transition: InternalTransition + ): Promise> { return Promise.resolve(this.routePromise) .then((route: Route) => this.checkForAbort(shouldContinue, route), null) .then(() => { @@ -149,7 +149,10 @@ export default class PrivateRouteInfo { .then(resolvedModel => this.becomeResolved(transition, resolvedModel)); } - becomeResolved(transition: Transition | null, resolvedContext: Dict): ResolvedRouteInfo { + becomeResolved( + transition: InternalTransition | null, + resolvedContext: Dict + ): ResolvedRouteInfo { let params = this.serialize(resolvedContext); if (transition) { @@ -175,7 +178,7 @@ export default class PrivateRouteInfo { ); } - shouldSupercede(routeInfo?: PrivateRouteInfo) { + shouldSupercede(routeInfo?: InternalRouteInfo) { // Prefer this newer routeInfo over `other` if: // 1) The other one doesn't exist // 2) The names don't match @@ -194,21 +197,21 @@ export default class PrivateRouteInfo { ); } - get route(): Route | undefined { + get route(): T | undefined { // _route could be set to either a route object or undefined, so we - // compare against a default reference to know when it's been set - if (this._route !== undefined) { - return this._route!; + // compare against null to know when it's been set + if (this._route !== null) { + return this._route; } return this.fetchRoute(); } - set route(route: Route | undefined) { + set route(route: T | undefined) { this._route = route; } - get routePromise(): Promise { + get routePromise(): Promise { if (this._routePromise) { return this._routePromise; } @@ -218,23 +221,21 @@ export default class PrivateRouteInfo { return this._routePromise!; } - set routePromise(routePromise: Promise) { + set routePromise(routePromise: Promise) { this._routePromise = routePromise; } - protected log(transition: Transition, message: string) { + protected log(transition: InternalTransition, message: string) { if (transition.log) { transition.log(this.name + ': ' + message); } } - private updateRoute(route: Route) { - // Store the name of the route on the route for easy checks later - route.routeName = this.name; + private updateRoute(route: T) { return (this.route = route); } - private runBeforeModelHook(transition: Transition) { + private runBeforeModelHook(transition: InternalTransition) { if (transition.trigger) { transition.trigger(true, 'willResolveModel', transition, this.route); } @@ -254,7 +255,7 @@ export default class PrivateRouteInfo { } private runAfterModelHook( - transition: Transition, + transition: InternalTransition, resolvedModel?: Dict ): Promise> { // Stash the resolved model on the payload. @@ -288,7 +289,7 @@ export default class PrivateRouteInfo { }, null); } - private stashResolvedModel(transition: Transition, resolvedModel?: Dict) { + private stashResolvedModel(transition: InternalTransition, resolvedModel: Dict) { transition.resolvedModels = transition.resolvedModels || {}; transition.resolvedModels[this.name] = resolvedModel; } @@ -298,15 +299,15 @@ export default class PrivateRouteInfo { return this._processRoute(route); } - private _processRoute(route: Route | Promise) { + private _processRoute(route: T | Promise) { // Setup a routePromise so that we can wait for asynchronously loaded routes this.routePromise = Promise.resolve(route); // Wait until the 'route' property has been updated when chaining to a route // that is a promise if (isPromise(route)) { - this.routePromise = this.routePromise.then(h => { - return this.updateRoute(h); + this.routePromise = this.routePromise.then(r => { + return this.updateRoute(r); }); // set to undefined to avoid recursive loop in the route getter return (this.route = undefined); @@ -318,14 +319,14 @@ export default class PrivateRouteInfo { } } -export class ResolvedRouteInfo extends PrivateRouteInfo { +export class ResolvedRouteInfo extends InternalRouteInfo { isResolved: boolean; constructor( - router: Router, + router: Router, name: string, paramNames: string[], params: Dict, - route: Route, + route: T, context?: Dict ) { super(router, name, paramNames, route); @@ -334,7 +335,7 @@ export class ResolvedRouteInfo extends PrivateRouteInfo { this.context = context; } - resolve(_shouldContinue?: Continuation, transition?: Transition): Promise { + resolve(_shouldContinue?: Continuation, transition?: InternalTransition): Promise { // A ResolvedRouteInfo just resolved with itself. if (transition && transition.resolvedModels) { transition.resolvedModels[this.name] = this.context!; @@ -343,20 +344,20 @@ export class ResolvedRouteInfo extends PrivateRouteInfo { } } -export class UnresolvedRouteInfoByParam extends PrivateRouteInfo { +export class UnresolvedRouteInfoByParam extends InternalRouteInfo { params: Dict = {}; constructor( - router: Router, + router: Router, name: string, paramNames: string[], params: Dict, - route?: Route + route?: T ) { super(router, name, paramNames, route); this.params = params; } - getModel(transition: Transition) { + getModel(transition: InternalTransition) { let fullParams = this.params; if (transition && transition.queryParams) { fullParams = {}; @@ -382,15 +383,15 @@ export class UnresolvedRouteInfoByParam extends PrivateRouteInfo { } } -export class UnresolvedRouteInfoByObject extends PrivateRouteInfo { +export class UnresolvedRouteInfoByObject extends InternalRouteInfo { serializer?: SerializerFunc; - constructor(router: Router, name: string, paramNames: string[], context: Dict) { + constructor(router: Router, name: string, paramNames: string[], context: Dict) { super(router, name, paramNames); this.context = context; this.serializer = this.router.getSerializer(name); } - getModel(transition: Transition) { + getModel(transition: InternalTransition) { if (this.router.log !== undefined) { this.router.log(this.name + ': resolving provided model'); } diff --git a/lib/router/router.ts b/lib/router/router.ts index aca02a68..1fb7f2d5 100644 --- a/lib/router/router.ts +++ b/lib/router/router.ts @@ -2,7 +2,11 @@ import RouteRecognizer, { MatchCallback, Params } from 'route-recognizer'; import { Promise } from 'rsvp'; import { Dict, Maybe } from './core'; import InternalRouteInfo, { Route, toReadOnlyRouteInfo } from './route-info'; -import { logAbort, Transition } from './transition'; +import InternalTransition, { + logAbort, + OpaqueTransition, + PublicTransition as Transition, +} from './transition'; import TransitionAbortedError from './transition-aborted-error'; import { TransitionIntent } from './transition-intent'; import NamedTransitionIntent from './transition-intent/named-transition-intent'; @@ -19,18 +23,7 @@ import { } from './utils'; export interface SerializerFunc { - (model: Dict, params: Dict): unknown; -} -export interface GetSerializerFunc { - (name: string): SerializerFunc | undefined; -} - -export interface GetHandlerFunc { - (name: string): Route | Promise; -} - -export interface DidTransitionFunc { - (routeInfos: InternalRouteInfo[]): void; + (model: {}, params: string[]): unknown; } export interface ParsedHandler { @@ -38,12 +31,12 @@ export interface ParsedHandler { names: string[]; } -export default abstract class Router { +export default abstract class Router { log?: (message: string) => void; - state?: TransitionState = undefined; - oldState: Maybe = undefined; - activeTransition?: Transition = undefined; - currentRouteInfos?: InternalRouteInfo[] = undefined; + state?: TransitionState = undefined; + oldState: Maybe> = undefined; + activeTransition?: InternalTransition = undefined; + currentRouteInfos?: InternalRouteInfo[] = undefined; _changedQueryParams?: Dict = undefined; currentSequence = 0; recognizer: RouteRecognizer; @@ -54,18 +47,18 @@ export default abstract class Router { this.reset(); } - abstract getRoute(name: string): Route | Promise; + abstract getRoute(name: string): T | Promise; abstract getSerializer(name: string): SerializerFunc | undefined; abstract updateURL(url: string): void; abstract replaceURL(url: string): void; abstract willTransition( - oldRouteInfos: InternalRouteInfo[], - newRouteInfos: InternalRouteInfo[], + oldRouteInfos: InternalRouteInfo[], + newRouteInfos: InternalRouteInfo[], transition: Transition ): void; - abstract didTransition(routeInfos: InternalRouteInfo[]): void; + abstract didTransition(routeInfos: InternalRouteInfo[]): void; abstract triggerEvent( - routeInfos: InternalRouteInfo[], + routeInfos: InternalRouteInfo[], ignoreFailure: boolean, name: string, args: unknown[] @@ -98,10 +91,10 @@ export default abstract class Router { queryParamsTransition( changelist: ChangeList, wasTransitioning: boolean, - oldState: TransitionState, - newState: TransitionState - ) { - fireQueryParamDidChange(this, newState, changelist); + oldState: TransitionState, + newState: TransitionState + ): OpaqueTransition { + this.fireQueryParamDidChange(newState, changelist); if (!wasTransitioning && this.activeTransition) { // One of the routes in queryParamsDidChange @@ -115,19 +108,18 @@ export default abstract class Router { // perform a URL update at the end. This gives // the user the ability to set the url update // method (default is replaceState). - let newTransition = new Transition(this, undefined, undefined); + let newTransition = new InternalTransition(this, undefined, undefined); newTransition.queryParamsOnly = true; - oldState.queryParams = finalizeQueryParamChange( - this, + oldState.queryParams = this.finalizeQueryParamChange( newState.routeInfos, newState.queryParams, newTransition ); newTransition.promise = newTransition.promise!.then( - (result: TransitionState | Route | Error | undefined) => { - updateURL(newTransition, oldState); + (result: TransitionState | Route | Error | undefined) => { + this._updateURL(newTransition, oldState); if (this.didTransition) { this.didTransition(this.currentRouteInfos!); } @@ -136,18 +128,607 @@ export default abstract class Router { null, promiseLabel('Transition complete') ); + return newTransition; } - } + } + + transitionByIntent(intent: TransitionIntent, isIntermediate: boolean): InternalTransition { + try { + return this.getTransitionByIntent(intent, isIntermediate); + } catch (e) { + return new InternalTransition(this, intent, undefined, e, undefined); + } + } + + private getTransitionByIntent( + intent: TransitionIntent, + isIntermediate: boolean + ): InternalTransition { + let wasTransitioning = !!this.activeTransition; + let oldState = wasTransitioning ? this.activeTransition!.state : this.state; + let newTransition: InternalTransition; + + let newState = intent.applyToState(oldState!, isIntermediate); + let queryParamChangelist = getChangelist(oldState!.queryParams, newState.queryParams); + + if (routeInfosEqual(newState.routeInfos, oldState!.routeInfos)) { + // This is a no-op transition. See if query params changed. + if (queryParamChangelist) { + let newTransition = this.queryParamsTransition( + queryParamChangelist, + wasTransitioning, + oldState!, + newState + ); + newTransition.queryParamsOnly = true; + return newTransition; + } + + // No-op. No need to create a new transition. + return this.activeTransition || new InternalTransition(this, undefined, undefined); + } + + if (isIntermediate) { + this.setupContexts(newState); + return this.activeTransition!; + } + + // Create a new transition to the destination route. + newTransition = new InternalTransition( + this, + intent, + newState, + undefined, + this.activeTransition + ); + + // transition is to same route with same params, only query params differ. + // not caught above probably because refresh() has been used + if (routeInfosSameExceptQueryParams(newState.routeInfos, oldState!.routeInfos)) { + newTransition.queryParamsOnly = true; + } + + // Abort and usurp any previously active transition. + if (this.activeTransition) { + this.activeTransition.abort(); + } + this.activeTransition = newTransition; + + // Transition promises by default resolve with resolved state. + // For our purposes, swap out the promise to resolve + // after the transition has been finalized. + newTransition.promise = newTransition.promise!.then( + (result: TransitionState) => { + return this.finalizeTransition(newTransition, result); + }, + null, + promiseLabel('Settle transition promise when transition is finalized') + ); + + if (!wasTransitioning) { + this.notifyExistingHandlers(newState, newTransition); + } + + this.fireQueryParamDidChange(newState, queryParamChangelist!); + + return newTransition; + } + + /** + @private + + Begins and returns a Transition based on the provided + arguments. Accepts arguments in the form of both URL + transitions and named transitions. + + @param {Router} router + @param {Array[Object]} args arguments passed to transitionTo, + replaceWith, or handleURL +*/ + private doTransition( + name?: string, + modelsArray: Dict[] = [], + isIntermediate = false + ): InternalTransition { + let lastArg = modelsArray[modelsArray.length - 1]; + let queryParams: Dict = {}; + + if (lastArg !== undefined && lastArg.hasOwnProperty('queryParams')) { + queryParams = modelsArray.pop()!.queryParams as Dict; + } + + let intent; + if (name === undefined) { + log(this, 'Updating query params'); + + // A query param update is really just a transition + // into the route you're already on. + let { routeInfos } = this.state!; + intent = new NamedTransitionIntent( + this, + routeInfos[routeInfos.length - 1].name, + undefined, + [], + queryParams + ); + } else if (name.charAt(0) === '/') { + log(this, 'Attempting URL transition to ' + name); + intent = new URLTransitionIntent(this, name); + } else { + log(this, 'Attempting transition to ' + name); + intent = new NamedTransitionIntent(this, name, undefined, modelsArray, queryParams); + } + + return this.transitionByIntent(intent, isIntermediate); + } + + /** + @private + + Updates the URL (if necessary) and calls `setupContexts` + to update the router's array of `currentRouteInfos`. + */ + private finalizeTransition( + transition: InternalTransition, + newState: TransitionState + ): T | Promise { + try { + log( + transition.router, + transition.sequence, + 'Resolved all models on destination route; finalizing transition.' + ); + + let routeInfos = newState.routeInfos; + + // Run all the necessary enter/setup/exit hooks + this.setupContexts(newState, transition); + + // Check if a redirect occurred in enter/setup + if (transition.isAborted) { + // TODO: cleaner way? distinguish b/w targetRouteInfos? + this.state!.routeInfos = this.currentRouteInfos!; + return Promise.reject(logAbort(transition)); + } + + this._updateURL(transition, newState); + + transition.isActive = false; + this.activeTransition = undefined; + + this.triggerEvent(this.currentRouteInfos!, true, 'didTransition', []); + + if (this.didTransition) { + this.didTransition(this.currentRouteInfos!); + } + + log(this, transition.sequence, 'TRANSITION COMPLETE.'); + + // Resolve with the final route. + return routeInfos[routeInfos.length - 1].route!; + } catch (e) { + if (!(e instanceof TransitionAbortedError)) { + //let erroneousHandler = routeInfos.pop(); + let infos = transition.state!.routeInfos; + transition.trigger(true, 'error', e, transition, infos[infos.length - 1].route); + transition.abort(); + } + + throw e; + } + } + + /** + @private + + Takes an Array of `RouteInfo`s, figures out which ones are + exiting, entering, or changing contexts, and calls the + proper route hooks. + + For example, consider the following tree of routes. Each route is + followed by the URL segment it handles. + + ``` + |~index ("/") + | |~posts ("/posts") + | | |-showPost ("/:id") + | | |-newPost ("/new") + | | |-editPost ("/edit") + | |~about ("/about/:id") + ``` + + Consider the following transitions: + + 1. A URL transition to `/posts/1`. + 1. Triggers the `*model` callbacks on the + `index`, `posts`, and `showPost` routes + 2. Triggers the `enter` callback on the same + 3. Triggers the `setup` callback on the same + 2. A direct transition to `newPost` + 1. Triggers the `exit` callback on `showPost` + 2. Triggers the `enter` callback on `newPost` + 3. Triggers the `setup` callback on `newPost` + 3. A direct transition to `about` with a specified + context object + 1. Triggers the `exit` callback on `newPost` + and `posts` + 2. Triggers the `serialize` callback on `about` + 3. Triggers the `enter` callback on `about` + 4. Triggers the `setup` callback on `about` + + @param {Router} transition + @param {TransitionState} newState +*/ + private setupContexts(newState: TransitionState, transition?: InternalTransition) { + let partition = this.partitionRoutes(this.state!, newState); + let i, l, route; + + for (i = 0, l = partition.exited.length; i < l; i++) { + route = partition.exited[i].route; + delete route!.context; + + if (route !== undefined) { + if (route.reset !== undefined) { + route.reset(true, transition); + } + + if (route.exit !== undefined) { + route.exit(transition); + } + } + } + + let oldState = (this.oldState = this.state); + this.state = newState; + let currentRouteInfos = (this.currentRouteInfos = partition.unchanged.slice()); + + try { + for (i = 0, l = partition.reset.length; i < l; i++) { + route = partition.reset[i].route; + if (route !== undefined) { + if (route.reset !== undefined) { + route.reset(false, transition); + } + } + } + + for (i = 0, l = partition.updatedContext.length; i < l; i++) { + this.routeEnteredOrUpdated( + currentRouteInfos, + partition.updatedContext[i], + false, + transition! + ); + } + + for (i = 0, l = partition.entered.length; i < l; i++) { + this.routeEnteredOrUpdated(currentRouteInfos, partition.entered[i], true, transition!); + } + } catch (e) { + this.state = oldState; + this.currentRouteInfos = oldState!.routeInfos; + throw e; + } + + this.state.queryParams = this.finalizeQueryParamChange( + currentRouteInfos, + newState.queryParams, + transition! + ); + } + + /** + @private + + Fires queryParamsDidChange event +*/ + private fireQueryParamDidChange(newState: TransitionState, queryParamChangelist: ChangeList) { + // If queryParams changed trigger event + if (queryParamChangelist) { + // This is a little hacky but we need some way of storing + // changed query params given that no activeTransition + // is guaranteed to have occurred. + this._changedQueryParams = queryParamChangelist.all; + this.triggerEvent(newState.routeInfos, true, 'queryParamsDidChange', [ + queryParamChangelist.changed, + queryParamChangelist.all, + queryParamChangelist.removed, + ]); + this._changedQueryParams = undefined; + } + } + + /** + @private + + Helper method used by setupContexts. Handles errors or redirects + that may happen in enter/setup. +*/ + private routeEnteredOrUpdated( + currentRouteInfos: InternalRouteInfo[], + routeInfo: InternalRouteInfo, + enter: boolean, + transition?: InternalTransition + ) { + let route = routeInfo.route, + context = routeInfo.context; + + function _routeEnteredOrUpdated(route: T) { + if (enter) { + if (route.enter !== undefined) { + route.enter(transition!); + } + } + + if (transition && transition.isAborted) { + throw new TransitionAbortedError(); + } + + route.context = context; + + if (route.contextDidChange !== undefined) { + route.contextDidChange(); + } + + if (route.setup !== undefined) { + route.setup(context!, transition!); + } + + if (transition && transition.isAborted) { + throw new TransitionAbortedError(); + } + + currentRouteInfos.push(routeInfo); + return route; + } + + // If the route doesn't exist, it means we haven't resolved the route promise yet + if (route === undefined) { + routeInfo.routePromise = routeInfo.routePromise.then(_routeEnteredOrUpdated); + } else { + _routeEnteredOrUpdated(route); + } + + return true; + } + + /** + @private + + This function is called when transitioning from one URL to + another to determine which routes are no longer active, + which routes are newly active, and which routes remain + active but have their context changed. + + Take a list of old routes and new routes and partition + them into four buckets: + + * unchanged: the route was active in both the old and + new URL, and its context remains the same + * updated context: the route was active in both the + old and new URL, but its context changed. The route's + `setup` method, if any, will be called with the new + context. + * exited: the route was active in the old URL, but is + no longer active. + * entered: the route was not active in the old URL, but + is now active. + + The PartitionedRoutes structure has four fields: + + * `updatedContext`: a list of `RouteInfo` objects that + represent routes that remain active but have a changed + context + * `entered`: a list of `RouteInfo` objects that represent + routes that are newly active + * `exited`: a list of `RouteInfo` objects that are no + longer active. + * `unchanged`: a list of `RouteInfo` objects that remain active. + + @param {Array[InternalRouteInfo]} oldRoutes a list of the route + information for the previous URL (or `[]` if this is the + first handled transition) + @param {Array[InternalRouteInfo]} newRoutes a list of the route + information for the new URL + + @return {Partition} +*/ + private partitionRoutes(oldState: TransitionState, newState: TransitionState) { + let oldRouteInfos = oldState.routeInfos; + let newRouteInfos = newState.routeInfos; + + let routes: RoutePartition = { + updatedContext: [], + exited: [], + entered: [], + unchanged: [], + reset: [], + }; + + let routeChanged, + contextChanged = false, + i, + l; + + for (i = 0, l = newRouteInfos.length; i < l; i++) { + let oldRouteInfo = oldRouteInfos[i], + newRouteInfo = newRouteInfos[i]; + + if (!oldRouteInfo || oldRouteInfo.route !== newRouteInfo.route) { + routeChanged = true; + } + + if (routeChanged) { + routes.entered.push(newRouteInfo); + if (oldRouteInfo) { + routes.exited.unshift(oldRouteInfo); + } + } else if (contextChanged || oldRouteInfo.context !== newRouteInfo.context) { + contextChanged = true; + routes.updatedContext.push(newRouteInfo); + } else { + routes.unchanged.push(oldRouteInfo); + } + } + + for (i = newRouteInfos.length, l = oldRouteInfos.length; i < l; i++) { + routes.exited.unshift(oldRouteInfos[i]); + } + + routes.reset = routes.updatedContext.slice(); + routes.reset.reverse(); + + return routes; + } + + private _updateURL(transition: OpaqueTransition, state: TransitionState) { + let urlMethod: string | null = transition.urlMethod; + + if (!urlMethod) { + return; + } + + let { routeInfos } = state; + let { name: routeName } = routeInfos[routeInfos.length - 1]; + let params: Dict = {}; + + for (let i = routeInfos.length - 1; i >= 0; --i) { + let routeInfo = routeInfos[i]; + merge(params, routeInfo.params); + if (routeInfo.route!.inaccessibleByURL) { + urlMethod = null; + } + } + + if (urlMethod) { + params.queryParams = transition._visibleQueryParams || state.queryParams; + let url = this.recognizer.generate(routeName, params as Params); + + // transitions during the initial transition must always use replaceURL. + // When the app boots, you are at a url, e.g. /foo. If some route + // redirects to bar as part of the initial transition, you don't want to + // add a history entry for /foo. If you do, pressing back will immediately + // hit the redirect again and take you back to /bar, thus killing the back + // button + let initial = transition.isCausedByInitialTransition; + + // say you are at / and you click a link to route /foo. In /foo's + // route, the transition is aborted using replacewith('/bar'). + // Because the current url is still /, the history entry for / is + // removed from the history. Clicking back will take you to the page + // you were on before /, which is often not even the app, thus killing + // the back button. That's why updateURL is always correct for an + // aborting transition that's not the initial transition + let replaceAndNotAborting = + urlMethod === 'replace' && !transition.isCausedByAbortingTransition; + + // because calling refresh causes an aborted transition, this needs to be + // special cased - if the initial transition is a replace transition, the + // urlMethod should be honored here. + let isQueryParamsRefreshTransition = transition.queryParamsOnly && urlMethod === 'replace'; + + // say you are at / and you a `replaceWith(/foo)` is called. Then, that + // transition is aborted with `replaceWith(/bar)`. At the end, we should + // end up with /bar replacing /. We are replacing the replace. We only + // will replace the initial route if all subsequent aborts are also + // replaces. However, there is some ambiguity around the correct behavior + // here. + let replacingReplace = + urlMethod === 'replace' && transition.isCausedByAbortingReplaceTransition; + + if (initial || replaceAndNotAborting || isQueryParamsRefreshTransition || replacingReplace) { + this.replaceURL!(url); + } else { + this.updateURL(url); + } + } + } + + private finalizeQueryParamChange( + resolvedHandlers: InternalRouteInfo[], + newQueryParams: Dict, + transition: OpaqueTransition + ) { + // We fire a finalizeQueryParamChange event which + // gives the new route hierarchy a chance to tell + // us which query params it's consuming and what + // their final values are. If a query param is + // no longer consumed in the final route hierarchy, + // its serialized segment will be removed + // from the URL. + + for (let k in newQueryParams) { + if (newQueryParams.hasOwnProperty(k) && newQueryParams[k] === null) { + delete newQueryParams[k]; + } + } + + let finalQueryParamsArray: { + key: string; + value: string; + visible: boolean; + }[] = []; + + this.triggerEvent(resolvedHandlers, true, 'finalizeQueryParamChange', [ + newQueryParams, + finalQueryParamsArray, + transition, + ]); + + if (transition) { + transition._visibleQueryParams = {}; + } + + let finalQueryParams: Dict = {}; + for (let i = 0, len = finalQueryParamsArray.length; i < len; ++i) { + let qp = finalQueryParamsArray[i]; + finalQueryParams[qp.key] = qp.value; + if (transition && qp.visible !== false) { + transition._visibleQueryParams[qp.key] = qp.value; + } + } + return finalQueryParams; + } + + private notifyExistingHandlers( + newState: TransitionState, + newTransition: InternalTransition + ) { + let oldRouteInfos = this.state!.routeInfos, + changing = [], + i, + oldRouteInfoLen, + oldHandler, + newRouteInfo; + + oldRouteInfoLen = oldRouteInfos.length; + for (i = 0; i < oldRouteInfoLen; i++) { + oldHandler = oldRouteInfos[i]; + newRouteInfo = newState.routeInfos[i]; + + if (!newRouteInfo || oldHandler.name !== newRouteInfo.name) { + break; + } + + if (!newRouteInfo.isResolved) { + changing.push(oldHandler); + } + } - // NOTE: this doesn't really belong here, but here - // it shall remain until our ES6 transpiler can - // handle cyclical deps. - transitionByIntent(intent: TransitionIntent, isIntermediate: boolean) { - try { - return getTransitionByIntent.apply(this, [intent, isIntermediate]); - } catch (e) { - return new Transition(this, intent, undefined, e, undefined); + if (oldRouteInfoLen > 0) { + let fromInfos = toReadOnlyRouteInfo(oldRouteInfos); + newTransition!.from = fromInfos[fromInfos.length - 1]; + } + + if (newState.routeInfos.length > 0) { + let toInfos = toReadOnlyRouteInfo(newState.routeInfos); + newTransition!.to = toInfos[toInfos.length - 1]; + } + + this.triggerEvent(oldRouteInfos, true, 'willTransition', [newTransition]); + if (this.willTransition) { + this.willTransition(oldRouteInfos, newState.routeInfos, newTransition); } } @@ -158,7 +739,7 @@ export default abstract class Router { */ reset() { if (this.state) { - forEach(this.state.routeInfos.slice().reverse(), function(routeInfo) { + forEach>(this.state.routeInfos.slice().reverse(), function(routeInfo) { let route = routeInfo.route; if (route !== undefined) { if (route.exit !== undefined) { @@ -193,7 +774,7 @@ export default abstract class Router { url = '/' + url; } - return doTransition(this, url).method(null); + return this.doTransition(url)!.method(null); } /** @@ -207,17 +788,17 @@ export default abstract class Router { transitionTo(name: string | { queryParams: Dict }, ...contexts: any[]) { if (typeof name === 'object') { contexts.push(name); - return doTransition(this, undefined, contexts, false); + return this.doTransition(undefined, contexts, false); } - return doTransition(this, name, contexts); + return this.doTransition(name, contexts); } intermediateTransitionTo(name: string, ...args: any[]) { - return doTransition(this, name, args, true); + return this.doTransition(name, args, true); } - refresh(pivotRoute?: Route) { + refresh(pivotRoute?: T) { let previousTransition = this.activeTransition; let state = previousTransition ? previousTransition.state : this.state; let routeInfos = state!.routeInfos; @@ -229,8 +810,8 @@ export default abstract class Router { log(this, 'Starting a refresh transition'); let name = routeInfos[routeInfos.length - 1].name; let intent = new NamedTransitionIntent( - name, this, + name, pivotRoute, [], this._changedQueryParams || state!.queryParams @@ -255,7 +836,7 @@ export default abstract class Router { @param {String} name the name of the route */ replaceWith(name: string) { - return doTransition(this, name).method('replace'); + return this.doTransition(name).method('replace'); } /** @@ -275,7 +856,7 @@ export default abstract class Router { // Construct a TransitionIntent with the provided params // and apply it to the present state of the router. - let intent = new NamedTransitionIntent(routeName, this, undefined, suppliedParams); + let intent = new NamedTransitionIntent(this, routeName, undefined, suppliedParams); let state = intent.applyToState(this.state!, false); let params: Params = {}; @@ -289,8 +870,8 @@ export default abstract class Router { return this.recognizer.generate(routeName, params); } - applyIntent(routeName: string, contexts: Dict[]) { - let intent = new NamedTransitionIntent(routeName, this, undefined, contexts); + applyIntent(routeName: string, contexts: Dict[]): TransitionState { + let intent = new NamedTransitionIntent(this, routeName, undefined, contexts); let state = (this.activeTransition && this.activeTransition.state) || this.state!; @@ -301,7 +882,7 @@ export default abstract class Router { routeName: string, contexts: any[], queryParams?: Dict, - _state?: TransitionState + _state?: TransitionState ) { let state = _state || this.state!, targetRouteInfos = state.routeInfos, @@ -328,11 +909,11 @@ export default abstract class Router { return false; } - let testState = new TransitionState(); + let testState = new TransitionState(); testState.routeInfos = targetRouteInfos.slice(0, index + 1); recogHandlers = recogHandlers.slice(0, index + 1); - let intent = new NamedTransitionIntent(targetHandler, this, undefined, contexts); + let intent = new NamedTransitionIntent(this, targetHandler, undefined, contexts); let newState = intent.applyToHandlers(testState, recogHandlers, targetHandler, true, true); @@ -365,507 +946,10 @@ export default abstract class Router { } } -function getTransitionByIntent(this: Router, intent: TransitionIntent, isIntermediate: boolean) { - let wasTransitioning = !!this.activeTransition; - let oldState = wasTransitioning ? this.activeTransition!.state : this.state; - let newTransition: Transition; - - let newState = intent.applyToState(oldState!, isIntermediate); - let queryParamChangelist = getChangelist(oldState!.queryParams, newState.queryParams); - - if (routeInfosEqual(newState.routeInfos, oldState!.routeInfos)) { - // This is a no-op transition. See if query params changed. - if (queryParamChangelist) { - newTransition = this.queryParamsTransition( - queryParamChangelist, - wasTransitioning, - oldState!, - newState - ); - if (newTransition) { - newTransition.queryParamsOnly = true; - return newTransition; - } - } - - // No-op. No need to create a new transition. - return this.activeTransition || new Transition(this, undefined, undefined); - } - - if (isIntermediate) { - setupContexts(this, newState); - return; - } - - // Create a new transition to the destination route. - newTransition = new Transition(this, intent, newState, undefined, this.activeTransition); - - // transition is to same route with same params, only query params differ. - // not caught above probably because refresh() has been used - if (routeInfosSameExceptQueryParams(newState.routeInfos, oldState!.routeInfos)) { - newTransition.queryParamsOnly = true; - } - - // Abort and usurp any previously active transition. - if (this.activeTransition) { - this.activeTransition.abort(); - } - this.activeTransition = newTransition; - - // Transition promises by default resolve with resolved state. - // For our purposes, swap out the promise to resolve - // after the transition has been finalized. - newTransition.promise = newTransition.promise!.then( - (result: TransitionState) => { - return finalizeTransition(newTransition, result); - }, - null, - promiseLabel('Settle transition promise when transition is finalized') - ); - - if (!wasTransitioning) { - notifyExistingHandlers(this, newState, newTransition); - } - - fireQueryParamDidChange(this, newState, queryParamChangelist!); - - return newTransition; -} - -/** - @private - - Fires queryParamsDidChange event -*/ -function fireQueryParamDidChange( - router: Router, - newState: TransitionState, - queryParamChangelist: ChangeList -) { - // If queryParams changed trigger event - if (queryParamChangelist) { - // This is a little hacky but we need some way of storing - // changed query params given that no activeTransition - // is guaranteed to have occurred. - router._changedQueryParams = queryParamChangelist.all; - router.triggerEvent(newState.routeInfos, true, 'queryParamsDidChange', [ - queryParamChangelist.changed, - queryParamChangelist.all, - queryParamChangelist.removed, - ]); - router._changedQueryParams = undefined; - } -} - -/** - @private - - Takes an Array of `RouteInfo`s, figures out which ones are - exiting, entering, or changing contexts, and calls the - proper route hooks. - - For example, consider the following tree of routes. Each route is - followed by the URL segment it handles. - - ``` - |~index ("/") - | |~posts ("/posts") - | | |-showPost ("/:id") - | | |-newPost ("/new") - | | |-editPost ("/edit") - | |~about ("/about/:id") - ``` - - Consider the following transitions: - - 1. A URL transition to `/posts/1`. - 1. Triggers the `*model` callbacks on the - `index`, `posts`, and `showPost` routes - 2. Triggers the `enter` callback on the same - 3. Triggers the `setup` callback on the same - 2. A direct transition to `newPost` - 1. Triggers the `exit` callback on `showPost` - 2. Triggers the `enter` callback on `newPost` - 3. Triggers the `setup` callback on `newPost` - 3. A direct transition to `about` with a specified - context object - 1. Triggers the `exit` callback on `newPost` - and `posts` - 2. Triggers the `serialize` callback on `about` - 3. Triggers the `enter` callback on `about` - 4. Triggers the `setup` callback on `about` - - @param {Router} transition - @param {TransitionState} newState -*/ -function setupContexts(router: Router, newState: TransitionState, transition?: Transition) { - let partition = partitionRoutes(router.state!, newState); - let i, l, route; - - for (i = 0, l = partition.exited.length; i < l; i++) { - route = partition.exited[i].route; - delete route!.context; - - if (route !== undefined) { - if (route.reset !== undefined) { - route.reset(true, transition); - } - - if (route.exit !== undefined) { - route.exit(transition); - } - } - } - - let oldState = (router.oldState = router.state); - router.state = newState; - let currentRouteInfos = (router.currentRouteInfos = partition.unchanged.slice()); - - try { - for (i = 0, l = partition.reset.length; i < l; i++) { - route = partition.reset[i].route; - if (route !== undefined) { - if (route.reset !== undefined) { - route.reset(false, transition); - } - } - } - - for (i = 0, l = partition.updatedContext.length; i < l; i++) { - routeEnteredOrUpdated(currentRouteInfos, partition.updatedContext[i], false, transition!); - } - - for (i = 0, l = partition.entered.length; i < l; i++) { - routeEnteredOrUpdated(currentRouteInfos, partition.entered[i], true, transition!); - } - } catch (e) { - router.state = oldState; - router.currentRouteInfos = oldState!.routeInfos; - throw e; - } - - router.state.queryParams = finalizeQueryParamChange( - router, - currentRouteInfos, - newState.queryParams, - transition! - ); -} - -/** - @private - - Helper method used by setupContexts. Handles errors or redirects - that may happen in enter/setup. -*/ -function routeEnteredOrUpdated( - currentRouteInfos: InternalRouteInfo[], - routeInfo: InternalRouteInfo, - enter: boolean, - transition?: Transition -) { - let route = routeInfo.route, - context = routeInfo.context; - - function _routeEnteredOrUpdated(route: Route) { - if (enter) { - if (route.enter !== undefined) { - route.enter(transition!); - } - } - - if (transition && transition.isAborted) { - throw new TransitionAbortedError(); - } - - route.context = context; - - if (route.contextDidChange !== undefined) { - route.contextDidChange(); - } - - if (route.setup !== undefined) { - route.setup(context!, transition!); - } - - if (transition && transition.isAborted) { - throw new TransitionAbortedError(); - } - - currentRouteInfos.push(routeInfo); - return route; - } - - // If the route doesn't exist, it means we haven't resolved the route promise yet - if (route === undefined) { - routeInfo.routePromise = routeInfo.routePromise.then(_routeEnteredOrUpdated); - } else { - _routeEnteredOrUpdated(route); - } - - return true; -} - -/** - @private - - This function is called when transitioning from one URL to - another to determine which routes are no longer active, - which routes are newly active, and which routes remain - active but have their context changed. - - Take a list of old routes and new routes and partition - them into four buckets: - - * unchanged: the route was active in both the old and - new URL, and its context remains the same - * updated context: the route was active in both the - old and new URL, but its context changed. The route's - `setup` method, if any, will be called with the new - context. - * exited: the route was active in the old URL, but is - no longer active. - * entered: the route was not active in the old URL, but - is now active. - - The PartitionedRoutes structure has four fields: - - * `updatedContext`: a list of `RouteInfo` objects that - represent routes that remain active but have a changed - context - * `entered`: a list of `RouteInfo` objects that represent - routes that are newly active - * `exited`: a list of `RouteInfo` objects that are no - longer active. - * `unchanged`: a list of `RouteInfo` objects that remain active. - - @param {Array[InternalRouteInfo]} oldRoutes a list of the route - information for the previous URL (or `[]` if this is the - first handled transition) - @param {Array[InternalRouteInfo]} newRoutes a list of the route - information for the new URL - - @return {Partition} -*/ -function partitionRoutes(oldState: TransitionState, newState: TransitionState) { - let oldRouteInfos = oldState.routeInfos; - let newRouteInfos = newState.routeInfos; - - let routes: RoutePartition = { - updatedContext: [], - exited: [], - entered: [], - unchanged: [], - reset: [], - }; - - let routeChanged, - contextChanged = false, - i, - l; - - for (i = 0, l = newRouteInfos.length; i < l; i++) { - let oldRouteInfo = oldRouteInfos[i], - newRouteInfo = newRouteInfos[i]; - - if (!oldRouteInfo || oldRouteInfo.route !== newRouteInfo.route) { - routeChanged = true; - } - - if (routeChanged) { - routes.entered.push(newRouteInfo); - if (oldRouteInfo) { - routes.exited.unshift(oldRouteInfo); - } - } else if (contextChanged || oldRouteInfo.context !== newRouteInfo.context) { - contextChanged = true; - routes.updatedContext.push(newRouteInfo); - } else { - routes.unchanged.push(oldRouteInfo); - } - } - - for (i = newRouteInfos.length, l = oldRouteInfos.length; i < l; i++) { - routes.exited.unshift(oldRouteInfos[i]); - } - - routes.reset = routes.updatedContext.slice(); - routes.reset.reverse(); - - return routes; -} - -function updateURL(transition: Transition, state: TransitionState, _inputUrl?: string) { - let urlMethod: string | null = transition.urlMethod; - - if (!urlMethod) { - return; - } - - let { router } = transition; - let { routeInfos } = state; - let { name: routeName } = routeInfos[routeInfos.length - 1]; - let params: Dict = {}; - - for (let i = routeInfos.length - 1; i >= 0; --i) { - let routeInfo = routeInfos[i]; - merge(params, routeInfo.params); - if (routeInfo.route!.inaccessibleByURL) { - urlMethod = null; - } - } - - if (urlMethod) { - params.queryParams = transition._visibleQueryParams || state.queryParams; - let url = router.recognizer.generate(routeName, params as Params); - - // transitions during the initial transition must always use replaceURL. - // When the app boots, you are at a url, e.g. /foo. If some route - // redirects to bar as part of the initial transition, you don't want to - // add a history entry for /foo. If you do, pressing back will immediately - // hit the redirect again and take you back to /bar, thus killing the back - // button - let initial = transition.isCausedByInitialTransition; - - // say you are at / and you click a link to route /foo. In /foo's - // route, the transition is aborted using replacewith('/bar'). - // Because the current url is still /, the history entry for / is - // removed from the history. Clicking back will take you to the page - // you were on before /, which is often not even the app, thus killing - // the back button. That's why updateURL is always correct for an - // aborting transition that's not the initial transition - let replaceAndNotAborting = urlMethod === 'replace' && !transition.isCausedByAbortingTransition; - - // because calling refresh causes an aborted transition, this needs to be - // special cased - if the initial transition is a replace transition, the - // urlMethod should be honored here. - let isQueryParamsRefreshTransition = transition.queryParamsOnly && urlMethod === 'replace'; - - // say you are at / and you a `replaceWith(/foo)` is called. Then, that - // transition is aborted with `replaceWith(/bar)`. At the end, we should - // end up with /bar replacing /. We are replacing the replace. We only - // will replace the initial route if all subsequent aborts are also - // replaces. However, there is some ambiguity around the correct behavior - // here. - let replacingReplace = - urlMethod === 'replace' && transition.isCausedByAbortingReplaceTransition; - - if (initial || replaceAndNotAborting || isQueryParamsRefreshTransition || replacingReplace) { - router.replaceURL!(url); - } else { - router.updateURL(url); - } - } -} - -/** - @private - - Updates the URL (if necessary) and calls `setupContexts` - to update the router's array of `currentRouteInfos`. - */ -function finalizeTransition( - transition: Transition, - newState: TransitionState -): Route | Promise { - try { - log( - transition.router, - transition.sequence, - 'Resolved all models on destination route; finalizing transition.' - ); - - let router = transition.router, - routeInfos = newState.routeInfos; - - // Run all the necessary enter/setup/exit hooks - setupContexts(router, newState, transition); - - // Check if a redirect occurred in enter/setup - if (transition.isAborted) { - // TODO: cleaner way? distinguish b/w targetRouteInfos? - router.state!.routeInfos = router.currentRouteInfos!; - return Promise.reject(logAbort(transition)); - } - - updateURL(transition, newState, (transition.intent! as URLTransitionIntent).url); - - transition.isActive = false; - router.activeTransition = undefined; - - router.triggerEvent(router.currentRouteInfos!, true, 'didTransition', []); - - if (router.didTransition) { - router.didTransition(router.currentRouteInfos!); - } - - log(router, transition.sequence, 'TRANSITION COMPLETE.'); - - // Resolve with the final route. - return routeInfos[routeInfos.length - 1].route!; - } catch (e) { - console.log(e); - if (!(e instanceof TransitionAbortedError)) { - //let erroneousHandler = routeInfos.pop(); - let infos = transition.state!.routeInfos; - transition.trigger(true, 'error', e, transition, infos[infos.length - 1].route); - transition.abort(); - } - - throw e; - } -} - -/** - @private - - Begins and returns a Transition based on the provided - arguments. Accepts arguments in the form of both URL - transitions and named transitions. - - @param {Router} router - @param {Array[Object]} args arguments passed to transitionTo, - replaceWith, or handleURL -*/ -function doTransition( - router: Router, - name?: string, - modelsArray: Dict[] = [], - isIntermediate = false +function routeInfosEqual( + routeInfos: InternalRouteInfo[], + otherRouteInfos: InternalRouteInfo[] ) { - let lastArg = modelsArray[modelsArray.length - 1]; - let queryParams: Dict = {}; - - if (lastArg !== undefined && lastArg.hasOwnProperty('queryParams')) { - queryParams = modelsArray.pop()!.queryParams as Dict; - } - - let intent; - if (name === undefined) { - log(router, 'Updating query params'); - - // A query param update is really just a transition - // into the route you're already on. - let { routeInfos } = router.state!; - intent = new NamedTransitionIntent( - routeInfos[routeInfos.length - 1].name, - router, - undefined, - [], - queryParams - ); - } else if (name.charAt(0) === '/') { - log(router, 'Attempting URL transition to ' + name); - intent = new URLTransitionIntent(name, router); - } else { - log(router, 'Attempting transition to ' + name); - intent = new NamedTransitionIntent(name, router, undefined, modelsArray, queryParams); - } - - return router.transitionByIntent(intent, isIntermediate); -} - -function routeInfosEqual(routeInfos: InternalRouteInfo[], otherRouteInfos: InternalRouteInfo[]) { if (routeInfos.length !== otherRouteInfos.length) { return false; } @@ -879,8 +963,8 @@ function routeInfosEqual(routeInfos: InternalRouteInfo[], otherRouteInfos: Inter } function routeInfosSameExceptQueryParams( - routeInfos: InternalRouteInfo[], - otherRouteInfos: InternalRouteInfo[] + routeInfos: InternalRouteInfo[], + otherRouteInfos: InternalRouteInfo[] ) { if (routeInfos.length !== otherRouteInfos.length) { return false; @@ -923,99 +1007,10 @@ function paramsEqual(params: Dict, otherParams: Dict) { return true; } -function finalizeQueryParamChange( - router: Router, - resolvedHandlers: InternalRouteInfo[], - newQueryParams: Dict, - transition: Transition -) { - // We fire a finalizeQueryParamChange event which - // gives the new route hierarchy a chance to tell - // us which query params it's consuming and what - // their final values are. If a query param is - // no longer consumed in the final route hierarchy, - // its serialized segment will be removed - // from the URL. - - for (let k in newQueryParams) { - if (newQueryParams.hasOwnProperty(k) && newQueryParams[k] === null) { - delete newQueryParams[k]; - } - } - - let finalQueryParamsArray: { - key: string; - value: string; - visible: boolean; - }[] = []; - - router.triggerEvent(resolvedHandlers, true, 'finalizeQueryParamChange', [ - newQueryParams, - finalQueryParamsArray, - transition, - ]); - - if (transition) { - transition._visibleQueryParams = {}; - } - - let finalQueryParams: Dict = {}; - for (let i = 0, len = finalQueryParamsArray.length; i < len; ++i) { - let qp = finalQueryParamsArray[i]; - finalQueryParams[qp.key] = qp.value; - if (transition && qp.visible !== false) { - transition._visibleQueryParams[qp.key] = qp.value; - } - } - return finalQueryParams; -} - -function notifyExistingHandlers( - router: Router, - newState: TransitionState, - newTransition: Transition -) { - let oldRouteInfos = router.state!.routeInfos, - changing = [], - i, - oldRouteInfoLen, - oldHandler, - newRouteInfo; - - oldRouteInfoLen = oldRouteInfos.length; - for (i = 0; i < oldRouteInfoLen; i++) { - oldHandler = oldRouteInfos[i]; - newRouteInfo = newState.routeInfos[i]; - - if (!newRouteInfo || oldHandler.name !== newRouteInfo.name) { - break; - } - - if (!newRouteInfo.isResolved) { - changing.push(oldHandler); - } - } - - if (oldRouteInfoLen > 0) { - let fromInfos = toReadOnlyRouteInfo(oldRouteInfos); - newTransition!.from = fromInfos[fromInfos.length - 1]; - } - - if (newState.routeInfos.length > 0) { - let toInfos = toReadOnlyRouteInfo(newState.routeInfos); - newTransition!.to = toInfos[toInfos.length - 1]; - } - - router.triggerEvent(oldRouteInfos, true, 'willTransition', [newTransition]); - if (router.willTransition) { - router.willTransition(oldRouteInfos, newState.routeInfos, newTransition); - } -} - -export interface RoutePartition { - updatedContext: InternalRouteInfo[]; - exited: InternalRouteInfo[]; - entered: InternalRouteInfo[]; - unchanged: InternalRouteInfo[]; - reset: InternalRouteInfo[]; +export interface RoutePartition { + updatedContext: InternalRouteInfo[]; + exited: InternalRouteInfo[]; + entered: InternalRouteInfo[]; + unchanged: InternalRouteInfo[]; + reset: InternalRouteInfo[]; } diff --git a/lib/router/transition-intent.ts b/lib/router/transition-intent.ts index 793ec095..61c148b8 100644 --- a/lib/router/transition-intent.ts +++ b/lib/router/transition-intent.ts @@ -1,14 +1,16 @@ -import { Dict } from './core'; +import { Route } from './route-info'; import Router from './router'; import TransitionState from './transition-state'; -export abstract class TransitionIntent { - data: Dict; - router: Router; - constructor(router: Router, data?: Dict) { +export type OpaqueIntent = TransitionIntent; + +export abstract class TransitionIntent { + data: {}; + router: Router; + constructor(router: Router, data: {} = {}) { this.router = router; - this.data = data || {}; + this.data = data; } - preTransitionState?: TransitionState; - abstract applyToState(oldState: TransitionState, isIntermidate: boolean): TransitionState; + preTransitionState?: TransitionState; + abstract applyToState(oldState: TransitionState, isIntermidate: boolean): TransitionState; } diff --git a/lib/router/transition-intent/named-transition-intent.ts b/lib/router/transition-intent/named-transition-intent.ts index 22bf92c4..ee7c38be 100644 --- a/lib/router/transition-intent/named-transition-intent.ts +++ b/lib/router/transition-intent/named-transition-intent.ts @@ -9,28 +9,29 @@ import { TransitionIntent } from '../transition-intent'; import TransitionState from '../transition-state'; import { extractQueryParams, isParam, merge } from '../utils'; -export default class NamedTransitionIntent extends TransitionIntent { +export default class NamedTransitionIntent extends TransitionIntent { name: string; pivotHandler?: Route; contexts: Dict[]; queryParams: Dict; - preTransitionState?: TransitionState = undefined; + preTransitionState?: TransitionState = undefined; constructor( + router: Router, name: string, - router: Router, pivotHandler: Route | undefined, contexts: Dict[] = [], - queryParams: Dict = {} + queryParams: Dict = {}, + data?: {} ) { - super(router); + super(router, data); this.name = name; this.pivotHandler = pivotHandler; this.contexts = contexts; this.queryParams = queryParams; } - applyToState(oldState: TransitionState, isIntermediate: boolean) { + applyToState(oldState: TransitionState, isIntermediate: boolean): TransitionState { // TODO: WTF fix me let partitionedArgs = extractQueryParams([this.name].concat(this.contexts as any)), pureArgs = partitionedArgs[0], @@ -42,14 +43,14 @@ export default class NamedTransitionIntent extends TransitionIntent { } applyToHandlers( - oldState: TransitionState, + oldState: TransitionState, parsedHandlers: ParsedHandler[], targetRouteName: string, isIntermediate: boolean, checkingIfActive: boolean ) { let i, len; - let newState = new TransitionState(); + let newState = new TransitionState(); let objects = this.contexts.slice(0); let invalidateIndex = parsedHandlers.length; @@ -140,7 +141,7 @@ export default class NamedTransitionIntent extends TransitionIntent { return newState; } - invalidateChildren(handlerInfos: InternalRouteInfo[], invalidateIndex: number) { + invalidateChildren(handlerInfos: InternalRouteInfo[], invalidateIndex: number) { for (let i = invalidateIndex, l = handlerInfos.length; i < l; ++i) { let handlerInfo = handlerInfos[i]; if (handlerInfo.isResolved) { @@ -160,7 +161,7 @@ export default class NamedTransitionIntent extends TransitionIntent { name: string, names: string[], objects: Dict[], - oldHandlerInfo: InternalRouteInfo, + oldHandlerInfo: InternalRouteInfo, _targetRouteName: string, i: number ) { @@ -199,7 +200,7 @@ export default class NamedTransitionIntent extends TransitionIntent { name: string, names: string[], objects: Dict[], - oldHandlerInfo: InternalRouteInfo + oldHandlerInfo: InternalRouteInfo ) { let params: Dict = {}; diff --git a/lib/router/transition-intent/url-transition-intent.ts b/lib/router/transition-intent/url-transition-intent.ts index adc1132d..5c4b0498 100644 --- a/lib/router/transition-intent/url-transition-intent.ts +++ b/lib/router/transition-intent/url-transition-intent.ts @@ -5,17 +5,17 @@ import TransitionState from '../transition-state'; import UnrecognizedURLError from '../unrecognized-url-error'; import { merge } from '../utils'; -export default class URLTransitionIntent extends TransitionIntent { - preTransitionState?: TransitionState; +export default class URLTransitionIntent extends TransitionIntent { + preTransitionState?: TransitionState; url: string; - constructor(url: string, router: Router) { - super(router); + constructor(router: Router, url: string, data?: {}) { + super(router, data); this.url = url; this.preTransitionState = undefined; } - applyToState(oldState: TransitionState) { - let newState = new TransitionState(); + applyToState(oldState: TransitionState) { + let newState = new TransitionState(); let results = this.router.recognizer.recognize(this.url), i, @@ -31,7 +31,7 @@ export default class URLTransitionIntent extends TransitionIntent { // Checks if a handler is accessible by URL. If it is not, an error is thrown. // For the case where the handler is loaded asynchronously, the error will be // thrown once it is loaded. - function checkHandlerAccessibility(handler: Route) { + function checkHandlerAccessibility(handler: T) { if (handler && handler.inaccessibleByURL) { throw new UnrecognizedURLError(_url); } @@ -43,24 +43,24 @@ export default class URLTransitionIntent extends TransitionIntent { let result = results[i]!; let name = result.handler as string; - let newHandlerInfo = new UnresolvedRouteInfoByParam(this.router, name, [], result.params); + let newRouteInfo = new UnresolvedRouteInfoByParam(this.router, name, [], result.params); - let handler = newHandlerInfo.route; + let route = newRouteInfo.route; - if (handler) { - checkHandlerAccessibility(handler); + if (route) { + checkHandlerAccessibility(route); } else { // If the hanlder is being loaded asynchronously, check if we can // access it after it has resolved - newHandlerInfo.routePromise = newHandlerInfo.routePromise.then(checkHandlerAccessibility); + newRouteInfo.routePromise = newRouteInfo.routePromise.then(checkHandlerAccessibility); } - let oldHandlerInfo = oldState.routeInfos[i]; - if (statesDiffer || newHandlerInfo.shouldSupercede(oldHandlerInfo)) { + let oldRouteInfo = oldState.routeInfos[i]; + if (statesDiffer || newRouteInfo.shouldSupercede(oldRouteInfo)) { statesDiffer = true; - newState.routeInfos[i] = newHandlerInfo; + newState.routeInfos[i] = newRouteInfo; } else { - newState.routeInfos[i] = oldHandlerInfo; + newState.routeInfos[i] = oldRouteInfo; } } diff --git a/lib/router/transition-state.ts b/lib/router/transition-state.ts index 6adc3f22..0ba9b75c 100644 --- a/lib/router/transition-state.ts +++ b/lib/router/transition-state.ts @@ -1,15 +1,15 @@ import { Promise } from 'rsvp'; import { Dict } from './core'; import InternalRouteInfo, { Continuation, Route } from './route-info'; -import { Transition } from './transition'; +import Transition from './transition'; import { forEach, promiseLabel } from './utils'; interface IParams { [key: string]: unknown; } -export default class TransitionState { - routeInfos: InternalRouteInfo[] = []; +export default class TransitionState { + routeInfos: InternalRouteInfo[] = []; queryParams: Dict = {}; params: IParams = {}; @@ -25,7 +25,7 @@ export default class TransitionState { return promiseLabel("'" + targetName + "': " + label); } - resolve(shouldContinue: Continuation, transition: Transition): Promise { + resolve(shouldContinue: Continuation, transition: Transition): Promise> { // First, calculate params for this state. This is useful // information to provide to the various route hooks. let params = this.params; @@ -75,7 +75,7 @@ export default class TransitionState { ); } - function proceed(resolvedRouteInfo: InternalRouteInfo): Promise { + function proceed(resolvedRouteInfo: InternalRouteInfo): Promise> { let wasAlreadyResolved = currentState.routeInfos[transition.resolveIndex].isResolved; // Swap the previously unresolved routeInfo with @@ -104,7 +104,7 @@ export default class TransitionState { ); } - function resolveOneRouteInfo(): TransitionState | Promise { + function resolveOneRouteInfo(): TransitionState | Promise { if (transition.resolveIndex === currentState.routeInfos.length) { // This is is the only possible // fulfill value of TransitionState#resolve @@ -125,6 +125,6 @@ export class TransitionError { public error: Error, public route: Route, public wasAborted: boolean, - public state: TransitionState + public state: TransitionState ) {} } diff --git a/lib/router/transition.ts b/lib/router/transition.ts index 63139021..eae98209 100644 --- a/lib/router/transition.ts +++ b/lib/router/transition.ts @@ -1,9 +1,9 @@ import { Promise } from 'rsvp'; -import { Dict, Maybe } from './core'; -import HandlerInfo, { IRouteInfo, Route } from './route-info'; +import { Dict, Maybe, Option } from './core'; +import InternalRouteInfo, { Route, RouteInfo } from './route-info'; import Router from './router'; import TransitionAborted, { ITransitionAbortedError } from './transition-aborted-error'; -import { TransitionIntent } from './transition-intent'; +import { OpaqueIntent } from './transition-intent'; import TransitionState, { TransitionError } from './transition-state'; import { log, promiseLabel } from './utils'; @@ -16,6 +16,9 @@ export type OnRejected = | undefined | null; +export type PublicTransition = Transition; +export type OpaqueTransition = PublicTransition; + /** A Transition is a thennable (a promise-like object) that represents an attempt to transition to another route. It can be aborted, either @@ -31,25 +34,25 @@ export type OnRejected = @param {Object} error @private */ -export class Transition { - state?: TransitionState; - from: Maybe = null; - to: Maybe = null; - router: Router; +export default class Transition implements Promise { + state?: TransitionState; + from: Maybe = null; + to: Maybe = null; + router: Router; data: Dict; - intent?: TransitionIntent; - resolvedModels: Dict | undefined>; + intent: Maybe; + resolvedModels: Dict>; queryParams: Dict; promise?: Promise; // Todo: Fix this shit its actually TransitionState | IHandler | undefined | Error error: Maybe; params: Dict; - handlerInfos: HandlerInfo[]; + routeInfos: InternalRouteInfo[]; targetName: Maybe; pivotHandler: Maybe; sequence: number; isAborted = false; isActive = true; - urlMethod = 'update'; + urlMethod: Option = 'update'; resolveIndex = 0; queryParamsOnly = false; isTransition = true; @@ -59,11 +62,11 @@ export class Transition { _visibleQueryParams: Dict = {}; constructor( - router: Router, - intent: TransitionIntent | undefined, - state: TransitionState | undefined, + router: Router, + intent: Maybe, + state: TransitionState | undefined, error: Maybe = undefined, - previousTransition: Maybe = undefined + previousTransition: Maybe> = undefined ) { this.state = state || router.state; this.intent = intent; @@ -74,7 +77,7 @@ export class Transition { this.promise = undefined; this.error = undefined; this.params = {}; - this.handlerInfos = []; + this.routeInfos = []; this.targetName = undefined; this.pivotHandler = undefined; this.sequence = -1; @@ -103,7 +106,7 @@ export class Transition { if (state) { this.params = state.params; this.queryParams = state.queryParams; - this.handlerInfos = state.routeInfos; + this.routeInfos = state.routeInfos; let len = state.routeInfos.length; if (len) { @@ -144,18 +147,6 @@ export class Transition { } } - // Todo Delete? - isExiting(handler: Route | string) { - let handlerInfos = this.handlerInfos; - for (let i = 0, len = handlerInfos.length; i < len; ++i) { - let handlerInfo = handlerInfos[i]; - if (handlerInfo.name === handler || handlerInfo.route === handler) { - return false; - } - } - return true; - } - /** The Transition's internal promise. Calling `.then` on this property is that same as calling `.then` on the Transition object itself, but @@ -197,11 +188,11 @@ export class Transition { @return {Promise} @public */ - then( - onFulfilled: OnFulfilled, - onRejected: OnRejected, - label: string - ) { + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, + label?: string + ): Promise { return this.promise!.then(onFulfilled, onRejected, label); } @@ -218,7 +209,7 @@ export class Transition { @return {Promise} @public */ - catch(onRejection: OnRejected, label: string) { + catch(onRejection?: OnRejected, T>, label?: string) { return this.promise!.catch(onRejection, label); } @@ -235,7 +226,7 @@ export class Transition { @return {Promise} @public */ - finally(callback: T | undefined, label?: string) { + finally(callback?: T | undefined, label?: string) { return this.promise!.finally(callback, label); } @@ -252,8 +243,9 @@ export class Transition { return this; } log(this.router, this.sequence, this.targetName + ': transition was aborted'); - - this.intent!.preTransitionState = this.router.state; + if (this.intent !== undefined && this.intent !== null) { + this.intent.preTransitionState = this.router.state; + } this.isAborted = true; this.isActive = false; this.router.activeTransition = undefined; @@ -273,7 +265,7 @@ export class Transition { retry() { // TODO: add tests for merged state retry()s this.abort(); - let newTransition = this.router.transitionByIntent(this.intent!, false); + let newTransition = this.router.transitionByIntent(this.intent as OpaqueIntent, false); // inheriting a `null` urlMethod is not valid // the urlMethod is only set to `null` when @@ -309,7 +301,7 @@ export class Transition { @return {Transition} this transition @public */ - method(method: string) { + method(method: Option) { this.urlMethod = method; return this; } @@ -319,7 +311,7 @@ export class Transition { ignoreFailure: boolean, _name: string, err?: Error, - transition?: Transition, + transition?: Transition, handler?: Route ) { this.trigger(ignoreFailure, _name, err, transition, handler); @@ -359,7 +351,7 @@ export class Transition { value that the final redirecting transition fulfills with @public */ - followRedirects(): Promise { + followRedirects(): Promise { let router = this.router; return this.promise!.catch(function(reason) { if (router.activeTransition) { @@ -386,12 +378,12 @@ export class Transition { Logs and returns an instance of TransitionAborted. */ -export function logAbort(transition: Transition): ITransitionAbortedError { +export function logAbort(transition: Transition): ITransitionAbortedError { log(transition.router, transition.sequence, 'detected abort.'); return new TransitionAborted(); } -export function isTransition(obj: Dict | undefined): obj is Transition { +export function isTransition(obj: Dict | undefined): obj is typeof Transition { return typeof obj === 'object' && obj instanceof Transition && obj.isTransition; } diff --git a/lib/router/utils.ts b/lib/router/utils.ts index ef1f77e3..f6e229e9 100644 --- a/lib/router/utils.ts +++ b/lib/router/utils.ts @@ -66,7 +66,7 @@ export function coerceQueryParamsToString(queryParams: Dict) { /** @private */ -export function log(router: Router, ...args: (string | number)[]): void { +export function log(router: Router, ...args: (string | number)[]): void { if (!router.log) { return; } diff --git a/lib/rsvp/index.d.ts b/lib/rsvp/index.d.ts index be00ef97..84bc81e8 100644 --- a/lib/rsvp/index.d.ts +++ b/lib/rsvp/index.d.ts @@ -21,14 +21,14 @@ declare module 'rsvp' { | undefined | null; export type OnRejected = - | ((reason: T) => TResult2 | PromiseLike) + | ((reason: any) => TResult2 | PromiseLike) | undefined | null; export interface Promise extends PromiseLike { then( - onFulfilled?: OnFulfilled, - onRejected?: OnRejected, + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, label?: string ): Promise; catch( diff --git a/tests/async_get_handler_test.ts b/tests/async_get_handler_test.ts index 3d20d468..77f36d73 100644 --- a/tests/async_get_handler_test.ts +++ b/tests/async_get_handler_test.ts @@ -7,14 +7,14 @@ import { createHandler } from './test_helpers'; // so that we avoid using Backburner to handle the async portions of // the test suite let handlers: Dict; -let router: Router; +let router: Router; QUnit.module('Async Get Handler', { beforeEach: function() { QUnit.config.testTimeout = 60000; handlers = {}; - class TestRouter extends Router { + class TestRouter extends Router { didTransition() {} willTransition() {} replaceURL() {} @@ -119,5 +119,5 @@ QUnit.test('calls hooks of lazily-resolved routes in order', function(assert) { 'order of operations is correct' ); done(); - }); + }, null); }); diff --git a/tests/handler_info_test.ts b/tests/handler_info_test.ts index 27bd78f8..80087b51 100644 --- a/tests/handler_info_test.ts +++ b/tests/handler_info_test.ts @@ -1,7 +1,8 @@ import { Transition } from 'router'; import { Dict } from 'router/core'; -import HandlerInfo, { +import RouteInfo, { ResolvedRouteInfo, + Route, UnresolvedRouteInfoByObject, UnresolvedRouteInfoByParam, } from 'router/route-info'; @@ -52,7 +53,7 @@ test('HandlerInfo#resolve resolves with a ResolvedHandlerInfo', function(assert) let handlerInfo = createHandlerInfo('stub'); handlerInfo .resolve(() => false, {} as Transition) - .then(function(resolvedHandlerInfo: HandlerInfo) { + .then(function(resolvedHandlerInfo: RouteInfo) { assert.ok(resolvedHandlerInfo instanceof ResolvedRouteInfo); }); }); @@ -112,7 +113,7 @@ test('HandlerInfo#resolve runs afterModel hook on handler', function(assert) { handlerInfo .resolve(noop, transition as Transition) - .then(function(resolvedHandlerInfo: HandlerInfo) { + .then(function(resolvedHandlerInfo: RouteInfo) { assert.equal(resolvedHandlerInfo.context, model, 'HandlerInfo resolved with correct model'); }); }); @@ -145,21 +146,21 @@ test('UnresolvedHandlerInfoByParam gets its model hook called', function(assert) test('UnresolvedHandlerInfoByObject does NOT get its model hook called', function(assert) { assert.expect(1); - class Handler extends UnresolvedRouteInfoByObject { + class TestRouteInfo extends UnresolvedRouteInfoByObject { route = createHandler('uresolved', { model: function() { assert.ok(false, "I shouldn't be called because I already have a context/model"); }, }); } - let handlerInfo = new Handler( + let routeInfo = new TestRouteInfo( new StubRouter(), 'unresolved', ['wat'], resolve({ name: 'dorkletons' }) ); - handlerInfo.resolve(noop, {} as Transition).then(function(resolvedHandlerInfo: HandlerInfo) { + routeInfo.resolve(noop, {} as Transition).then(function(resolvedHandlerInfo: RouteInfo) { assert.equal(resolvedHandlerInfo.context!.name, 'dorkletons'); }); }); diff --git a/tests/query_params_test.ts b/tests/query_params_test.ts index b91a925a..69e06402 100644 --- a/tests/query_params_test.ts +++ b/tests/query_params_test.ts @@ -1,7 +1,7 @@ import { MatchCallback } from 'route-recognizer'; import Router, { Route, Transition } from 'router'; import { Dict, Maybe } from 'router/core'; -import HandlerInfo from 'router/route-info'; +import RouteInfo from 'router/route-info'; import { Promise } from 'rsvp'; import { createHandler, @@ -12,7 +12,7 @@ import { trigger, } from './test_helpers'; -let router: Router, handlers: Dict, expectedUrl: Maybe; +let router: Router, handlers: Dict, expectedUrl: Maybe; let scenarios = [ { name: 'Sync Get Handler', @@ -45,10 +45,15 @@ scenarios.forEach(function(scenario) { }); function map(assert: Assert, fn: MatchCallback) { - class TestRouter extends Router { + class TestRouter extends Router { didTransition() {} willTransition() {} - triggerEvent(handlerInfos: HandlerInfo[], ignoreFailure: boolean, name: string, args: any[]) { + triggerEvent( + handlerInfos: RouteInfo[], + ignoreFailure: boolean, + name: string, + args: any[] + ) { trigger(handlerInfos, ignoreFailure, name, ...args); } replaceURL(name: string) { diff --git a/tests/router_test.ts b/tests/router_test.ts index 0b3a3a10..6c258b8a 100644 --- a/tests/router_test.ts +++ b/tests/router_test.ts @@ -1,7 +1,7 @@ import { MatchCallback } from 'route-recognizer'; import Router, { Route, Transition } from 'router'; import { Dict, Maybe } from 'router/core'; -import HandlerInfo from 'router/route-info'; +import RouteInfo from 'router/route-info'; import { SerializerFunc } from 'router/router'; import { Promise, reject } from 'rsvp'; import { @@ -9,6 +9,7 @@ import { createHandler, flushBackburner, handleURL, + isExiting, module, replaceWith, shouldNotHappen, @@ -18,7 +19,7 @@ import { trigger, } from './test_helpers'; -let router: Router; +let router: Router; let url: string; let handlers: Dict; @@ -83,13 +84,18 @@ scenarios.forEach(function(scenario) { }); function map(assert: Assert, fn: MatchCallback) { - class TestRouter extends Router { + class TestRouter extends Router { didTransition() {} willTransition() {} replaceURL(name: string) { this.updateURL(name); } - triggerEvent(handlerInfos: HandlerInfo[], ignoreFailure: boolean, name: string, args: any[]) { + triggerEvent( + handlerInfos: RouteInfo[], + ignoreFailure: boolean, + name: string, + args: any[] + ) { trigger(handlerInfos, ignoreFailure, name, ...args); } @@ -133,16 +139,12 @@ scenarios.forEach(function(scenario) { }); test('Handling an invalid URL returns a rejecting promise', function(assert) { - router.handleURL('/unknown').then( - shouldNotHappen(assert), - function(e: Error) { - assert.equal(e.name, 'UnrecognizedURLError', 'error.name is UnrecognizedURLError'); - }, - shouldNotHappen(assert) - ); + router.handleURL('/unknown').then(shouldNotHappen(assert), function(e: Error) { + assert.equal(e.name, 'UnrecognizedURLError', 'error.name is UnrecognizedURLError'); + }); }); - function routePath(infos: HandlerInfo[]) { + function routePath(infos: RouteInfo[]) { let path = []; for (let i = 0, l = infos.length; i < l; i++) { @@ -348,8 +350,9 @@ scenarios.forEach(function(scenario) { return router.transitionTo('postDetails', { id: 1 }); }, shouldNotHappen(assert)) - .then(function() { + .then(function(value) { assert.deepEqual(contexts, [{ id: 1 }], 'parent context is available'); + return value; }, shouldNotHappen(assert)); }); @@ -769,7 +772,7 @@ scenarios.forEach(function(scenario) { }, setup: function(posts: Dict, transition: Transition) { - assert.ok(!transition.isExiting(this as Route)); + assert.ok(!isExiting(this as Route, transition.routeInfos)); assert.equal( posts, allPosts, @@ -779,7 +782,7 @@ scenarios.forEach(function(scenario) { }, exit: function(transition: Transition) { - assert.ok(transition.isExiting(this as Route)); + assert.ok(isExiting(this as Route, transition.routeInfos)); }, }), @@ -1159,7 +1162,7 @@ scenarios.forEach(function(scenario) { }), }; router.triggerEvent = function( - handlerInfos: HandlerInfo[], + handlerInfos: RouteInfo[], ignoreFailure: boolean, name: string, args: any[] @@ -2000,13 +2003,13 @@ scenarios.forEach(function(scenario) { router.transitionTo('index').then(function() { if (errorCount === 1) { // transition back here to test transitionTo error handling. - return router .transitionTo('showPost', reject('borf!')) .then(shouldNotHappen(assert), function(e: Error) { assert.equal(e, 'borf!', 'got thing'); }); } + return; }, shouldNotHappen(assert)); }, }, @@ -2494,6 +2497,7 @@ scenarios.forEach(function(scenario) { lastTransition = transition; return router.transitionTo('/login'); } + return; }, }), login: createHandler('login', { @@ -2689,24 +2693,20 @@ scenarios.forEach(function(scenario) { assert.ok(true, 'Failure handler called for index'); return router.transitionTo('/index').abort(); }) - .then(shouldNotHappen(assert), function() { + .then(shouldNotHappen(assert), function() { assert.ok(true, 'Failure handler called for /index'); hooksShouldBeCalled = true; return router.transitionTo('index'); }) - .then(function() { + .then(function() { assert.ok(true, 'Success handler called for index'); hooksShouldBeCalled = false; return router.transitionTo('about').abort(); }, shouldNotHappen(assert)) - .then( - shouldNotHappen(assert), - function() { - assert.ok(true, 'failure handler called for about'); - return router.transitionTo('/about').abort(); - }, - shouldNotHappen(assert) - ) + .then(shouldNotHappen(assert), function() { + assert.ok(true, 'failure handler called for about'); + return router.transitionTo('/about').abort(); + }) .then(shouldNotHappen(assert), function() { assert.ok(true, 'failure handler called for /about'); hooksShouldBeCalled = true; @@ -2727,8 +2727,8 @@ scenarios.forEach(function(scenario) { router .handleURL('/index') - .then(function(result: Dict) { - assert.ok(result.borfIndex, 'resolved to index handler'); + .then(function(route: Route) { + assert.ok((route as any)['borfIndex'], 'resolved to index handler'); return router.transitionTo('about'); }, shouldNotHappen(assert)) .then(function(result: Dict) { @@ -2739,9 +2739,8 @@ scenarios.forEach(function(scenario) { test('transitions have a .promise property', function(assert) { assert.expect(2); - router - .handleURL('/index') - .promise.then(function() { + router.handleURL('/index').promise! + .then(function() { let promise = router.transitionTo('about').abort().promise; assert.ok(promise, 'promise exists on aborted transitions'); return promise; @@ -3215,7 +3214,7 @@ test("exceptions thrown from model hooks aren't swallowed", function(assert) { delete handlers.index.enter; return router.handleURL('/index'); }) - .then(shouldNotHappen(assert), function(reason: string) { + .then(shouldNotHappen(assert), function(reason: string) { assert.equal(reason, 'OMG SETUP', "setup's error was propagated"); delete handlers.index.setup; }); @@ -3829,7 +3828,7 @@ test("A failed handler's setup shouldn't prevent future transitions", function(a .then(function() { return router.handleURL('/admin/posts'); }) - .then(shouldNotHappen(assert), function(e: Error) { + .then(shouldNotHappen(assert), function(e: Error) { assert.equal(e.name, 'UnrecognizedURLError', 'error.name is UnrecognizedURLError'); }); }); diff --git a/tests/test_helpers.ts b/tests/test_helpers.ts index f45a0ee6..8079fc7d 100644 --- a/tests/test_helpers.ts +++ b/tests/test_helpers.ts @@ -1,7 +1,7 @@ import Backburner from 'backburner'; import Router, { Route, Transition } from 'router'; import { Dict } from 'router/core'; -import HandlerInfo, { UnresolvedRouteInfoByParam } from 'router/route-info'; +import RouteInfo, { UnresolvedRouteInfoByParam } from 'router/route-info'; import TransitionAbortedError from 'router/transition-aborted-error'; import { UnrecognizedURLError } from 'router/unrecognized-url-error'; import { configure, resolve } from 'rsvp'; @@ -51,7 +51,7 @@ function assertAbort(assert: Assert) { // the backburner queue. Helpful for when you want to write // tests that avoid .then callbacks. function transitionTo( - router: Router, + router: Router, path: string | { queryParams: Dict }, ...context: any[] ) { @@ -60,19 +60,19 @@ function transitionTo( return result; } -function transitionToWithAbort(assert: Assert, router: Router, path: string) { +function transitionToWithAbort(assert: Assert, router: Router, path: string) { let args = [path]; router.transitionTo.apply(router, args).then(shouldNotHappen, assertAbort(assert)); flushBackburner(); } -function replaceWith(router: Router, path: string) { +function replaceWith(router: Router, path: string) { let result = router.transitionTo.apply(router, [path]).method('replace'); flushBackburner(); return result; } -function handleURL(router: Router, url: string) { +function handleURL(router: Router, url: string) { let result = router.handleURL.apply(router, [url]); flushBackburner(); return result; @@ -80,12 +80,23 @@ function handleURL(router: Router, url: string) { function shouldNotHappen(assert: Assert, _message?: string) { let message = _message || 'this .then handler should not be called'; - return function _shouldNotHappen(error: Error) { + return function _shouldNotHappen(error: any) { console.error(error.stack); // eslint-disable-line assert.ok(false, message); + return error; }; } +export function isExiting(route: Route | string, routeInfos: RouteInfo[]) { + for (let i = 0, len = routeInfos.length; i < len; ++i) { + let routeInfo = routeInfos[i]; + if (routeInfo.name === route || routeInfo.route === route) { + return false; + } + } + return true; +} + function stubbedHandlerInfoFactory(name: string, props: Dict) { let obj = Object.create(props); obj._handlerInfoType = name; @@ -121,7 +132,7 @@ export function createHandler(name: string, options?: Dict): Route { ); } -export class StubRouter extends Router { +export class StubRouter extends Router { getRoute(_name: string) { return {} as Route; } @@ -135,17 +146,17 @@ export class StubRouter extends Router { throw new Error('Method not implemented.'); } willTransition( - _oldHandlerInfos: HandlerInfo[], - _newHandlerInfos: HandlerInfo[], + _oldHandlerInfos: RouteInfo[], + _newHandlerInfos: RouteInfo[], _transition: Transition ): void { throw new Error('Method not implemented.'); } - didTransition(_handlerInfos: HandlerInfo[]): void { + didTransition(_handlerInfos: RouteInfo[]): void { throw new Error('Method not implemented.'); } triggerEvent( - _handlerInfos: HandlerInfo[], + _handlerInfos: RouteInfo[], _ignoreFailure: boolean, _name: string, _args: unknown[] @@ -154,9 +165,9 @@ export class StubRouter extends Router { } } -export function createHandlerInfo(name: string, options: Dict = {}): HandlerInfo { - class Stub extends HandlerInfo { - constructor(name: string, router: Router, handler?: Route) { +export function createHandlerInfo(name: string, options: Dict = {}): RouteInfo { + class Stub extends RouteInfo { + constructor(name: string, router: Router, handler?: Route) { super(router, name, [], handler); } getModel(_transition: Transition) { @@ -176,7 +187,7 @@ export function createHandlerInfo(name: string, options: Dict = {}): Ha } export function trigger( - handlerInfos: HandlerInfo[], + handlerInfos: RouteInfo[], ignoreFailure: boolean, name: string, ...args: any[] diff --git a/tests/transition_intent_test.ts b/tests/transition_intent_test.ts index 6082c936..d71e3bdf 100644 --- a/tests/transition_intent_test.ts +++ b/tests/transition_intent_test.ts @@ -5,7 +5,7 @@ import { createHandler, module, test } from './test_helpers'; import Router, { Route, Transition } from 'router'; import { Dict } from 'router/core'; -import HandlerInfo, { +import InternalRouteInfo, { ResolvedRouteInfo, UnresolvedRouteInfoByObject, UnresolvedRouteInfoByParam, @@ -32,7 +32,7 @@ let scenarios = [ ]; scenarios.forEach(function(scenario) { - class TestRouter extends Router { + class TestRouter extends Router { getSerializer(_name: string) { return () => {}; } @@ -43,17 +43,17 @@ scenarios.forEach(function(scenario) { throw new Error('Method not implemented.'); } willTransition( - _oldHandlerInfos: HandlerInfo[], - _newHandlerInfos: HandlerInfo[], + _oldHandlerInfos: InternalRouteInfo[], + _newHandlerInfos: InternalRouteInfo[], _transition: Transition ): void { throw new Error('Method not implemented.'); } - didTransition(_handlerInfos: HandlerInfo[]): void { + didTransition(_handlerInfos: InternalRouteInfo[]): void { throw new Error('Method not implemented.'); } triggerEvent( - _handlerInfos: HandlerInfo[], + _handlerInfos: InternalRouteInfo[], _ignoreFailure: boolean, _name: string, _args: unknown[] @@ -65,11 +65,15 @@ scenarios.forEach(function(scenario) { } } - let router: Router; + let router: Router; // Asserts that a handler from a handlerInfo equals an expected valued. // Returns a promise during async scenarios to wait until the handler is ready. - function assertHandlerEquals(assert: Assert, handlerInfo: HandlerInfo, expected: Route) { + function assertHandlerEquals( + assert: Assert, + handlerInfo: InternalRouteInfo, + expected: Route + ) { if (!scenario.async) { return assert.equal(handlerInfo.route, expected); } else { @@ -146,7 +150,7 @@ scenarios.forEach(function(scenario) { test('URLTransitionIntent can be applied to an empty state', function(assert) { let state = new TransitionState(); - let intent = new URLTransitionIntent('/foo/bar', router); + let intent = new URLTransitionIntent(router, '/foo/bar'); let newState = intent.applyToState(state); let handlerInfos = newState.routeInfos; @@ -177,7 +181,7 @@ scenarios.forEach(function(scenario) { // different. state.routeInfos = [startingHandlerInfo]; - let intent = new URLTransitionIntent('/foo/bar', router); + let intent = new URLTransitionIntent(router, '/foo/bar'); let newState = intent.applyToState(state); let handlerInfos = newState.routeInfos; @@ -201,7 +205,7 @@ scenarios.forEach(function(scenario) { state.routeInfos = [startingHandlerInfo]; - let intent = new URLTransitionIntent('/foo/bar', router); + let intent = new URLTransitionIntent(router, '/foo/bar'); let newState = intent.applyToState(state); let handlerInfos = newState.routeInfos; @@ -233,7 +237,7 @@ scenarios.forEach(function(scenario) { state.routeInfos = [startingHandlerInfo]; - let intent = new URLTransitionIntent('/articles/123/comments/456', router); + let intent = new URLTransitionIntent(router, '/articles/123/comments/456'); let newState = intent.applyToState(state); let handlerInfos = newState.routeInfos; @@ -257,7 +261,7 @@ scenarios.forEach(function(scenario) { state.routeInfos = [startingHandlerInfo]; - let intent = new URLTransitionIntent('/foo/bar', router); + let intent = new URLTransitionIntent(router, '/foo/bar'); let newState = intent.applyToState(state); let handlerInfos = newState.routeInfos; @@ -290,7 +294,7 @@ scenarios.forEach(function(scenario) { state.routeInfos = [startingHandlerInfo]; - let intent = new NamedTransitionIntent('comments', router, undefined, [article, comment]); + let intent = new NamedTransitionIntent(router, 'comments', undefined, [article, comment]); let newState = intent.applyToState(state, false); let handlerInfos = newState.routeInfos; diff --git a/tests/transition_state_test.ts b/tests/transition_state_test.ts index 2f999b26..d33e751b 100644 --- a/tests/transition_state_test.ts +++ b/tests/transition_state_test.ts @@ -2,6 +2,7 @@ import { Transition } from 'router'; import { Dict } from 'router/core'; import { Continuation, + Route, UnresolvedRouteInfoByObject, UnresolvedRouteInfoByParam, } from 'router/route-info'; @@ -56,7 +57,7 @@ test("#resolve delegates to handleInfo objects' resolve()", function(assert) { return Promise.resolve(false); } - state.resolve(keepGoing, {} as Transition).then(function(result: TransitionState) { + state.resolve(keepGoing, {} as Transition).then(function(result: TransitionState) { assert.deepEqual(result.routeInfos, resolvedHandlerInfos); }); }); @@ -123,7 +124,7 @@ test('Integration w/ HandlerInfos', function(assert) { state .resolve(noop, transition as Transition) - .then(function(result: TransitionState) { + .then(function(result: TransitionState) { let models = []; for (let i = 0; i < result.routeInfos.length; i++) { models.push(result.routeInfos[i].context);