Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify TransitionState resolution system. #306

Merged
merged 6 commits into from
Nov 9, 2020
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions lib/router/route-info.ts
Original file line number Diff line number Diff line change
@@ -14,6 +14,25 @@ interface IModel {
id?: string | number;
}

interface AbortableTransition<T extends boolean, R extends Route> extends InternalTransition<R> {
isAborted: T;
}

function checkForAbort<U, T extends AbortableTransition<I, R>, R extends Route, I extends boolean>(
transition: T,
value: U
): I extends true ? never : U;
function checkForAbort<U, T extends AbortableTransition<I, R>, R extends Route, I extends boolean>(
transition: T,
value: U
): never | U {
if (transition.isAborted) {
throw new Error('Transition aborted');
}

return value;
}

export interface Route {
inaccessibleByURL?: boolean;
routeName: string;
@@ -40,8 +59,6 @@ export interface Route {
buildRouteInfoMetadata?(): unknown;
}

export type Continuation = () => PromiseLike<boolean> | boolean;

export interface RouteInfo {
readonly name: string;
readonly parent: RouteInfo | RouteInfoWithAttributes | null;
@@ -222,16 +239,13 @@ export default class InternalRouteInfo<T extends Route> {
return this.params || {};
}

resolve(
shouldContinue: Continuation,
transition: InternalTransition<T>
): Promise<ResolvedRouteInfo<T>> {
resolve(transition: InternalTransition<T>): Promise<ResolvedRouteInfo<T>> {
return Promise.resolve(this.routePromise)
.then((route: Route) => this.checkForAbort(shouldContinue, route))
.then((route: Route) => checkForAbort(transition, route))
.then(() => this.runBeforeModelHook(transition))
.then(() => this.checkForAbort(shouldContinue, null))
.then(() => checkForAbort(transition, null))
.then(() => this.getModel(transition))
.then((resolvedModel) => this.checkForAbort(shouldContinue, resolvedModel))
.then((resolvedModel) => checkForAbort(transition, resolvedModel))
.then((resolvedModel) => this.runAfterModelHook(transition, resolvedModel))
.then((resolvedModel) => this.becomeResolved(transition, resolvedModel));
}
@@ -375,14 +389,6 @@ export default class InternalRouteInfo<T extends Route> {
});
}

private checkForAbort<T>(shouldContinue: Continuation, value: T) {
return Promise.resolve(shouldContinue()).then(function () {
// We don't care about shouldContinue's resolve value;
// pass along the original value passed to this fn.
return value;
}, null);
}

private stashResolvedModel(transition: InternalTransition<T>, resolvedModel: Dict<unknown>) {
transition.resolvedModels = transition.resolvedModels || {};
transition.resolvedModels[this.name] = resolvedModel;
@@ -429,10 +435,7 @@ export class ResolvedRouteInfo<T extends Route> extends InternalRouteInfo<T> {
this.context = context;
}

resolve(
_shouldContinue: Continuation,
transition: InternalTransition<T>
): Promise<InternalRouteInfo<T>> {
resolve(transition: InternalTransition<T>): Promise<InternalRouteInfo<T>> {
// A ResolvedRouteInfo just resolved with itself.
if (transition && transition.resolvedModels) {
transition.resolvedModels[this.name] = this.context as Dict<unknown>;
159 changes: 79 additions & 80 deletions lib/router/transition-state.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,84 @@
import { Promise } from 'rsvp';
import { Dict } from './core';
import InternalRouteInfo, { Continuation, Route } from './route-info';
import InternalRouteInfo, { Route, ResolvedRouteInfo } from './route-info';
import Transition from './transition';
import { forEach, promiseLabel } from './utils';

interface IParams {
[key: string]: unknown;
}

function handleError<T extends Route>(
currentState: TransitionState<T>,
transition: Transition<T>,
error: Error
): never {
// This is the only possible
// reject value of TransitionState#resolve
let routeInfos = currentState.routeInfos;
let errorHandlerIndex =
transition.resolveIndex >= routeInfos.length ? routeInfos.length - 1 : transition.resolveIndex;

let wasAborted = transition.isAborted;

throw new TransitionError(
error,
currentState.routeInfos[errorHandlerIndex].route!,
wasAborted,
currentState
);
}

function resolveOneRouteInfo<T extends Route>(
currentState: TransitionState<T>,
transition: Transition<T>
): void | Promise<void> {
if (transition.resolveIndex === currentState.routeInfos.length) {
// This is is the only possible
// fulfill value of TransitionState#resolve
return;
}

let routeInfo = currentState.routeInfos[transition.resolveIndex];

return routeInfo
.resolve(transition)
.then(proceed.bind(null, currentState, transition), null, currentState.promiseLabel('Proceed'));
}

function proceed<T extends Route>(
currentState: TransitionState<T>,
transition: Transition<T>,
resolvedRouteInfo: ResolvedRouteInfo<T>
): void | Promise<void> {
let wasAlreadyResolved = currentState.routeInfos[transition.resolveIndex].isResolved;

// Swap the previously unresolved routeInfo with
// the resolved routeInfo
currentState.routeInfos[transition.resolveIndex++] = resolvedRouteInfo;

if (!wasAlreadyResolved) {
// Call the redirect hook. The reason we call it here
// vs. afterModel is so that redirects into child
// routes don't re-run the model hooks for this
// already-resolved route.
let { route } = resolvedRouteInfo;
if (route !== undefined) {
if (route.redirect) {
route.redirect(resolvedRouteInfo.context as Dict<unknown>, transition);
}
}
}

// Proceed after ensuring that the redirect hook
// didn't abort this transition by transitioning elsewhere.
if (transition.isAborted) {
throw new Error('Transition aborted');
}

return resolveOneRouteInfo(currentState, transition);
}

export default class TransitionState<T extends Route> {
routeInfos: InternalRouteInfo<T>[] = [];
queryParams: Dict<unknown> = {};
@@ -25,7 +96,7 @@ export default class TransitionState<T extends Route> {
return promiseLabel("'" + targetName + "': " + label);
}

resolve(shouldContinue: Continuation, transition: Transition<T>): Promise<TransitionState<T>> {
resolve(transition: Transition<T>): Promise<TransitionState<T>> {
// First, calculate params for this state. This is useful
// information to provide to the various route hooks.
let params = this.params;
@@ -36,87 +107,15 @@ export default class TransitionState<T extends Route> {

transition.resolveIndex = 0;

let currentState = this;
let wasAborted = false;

// The prelude RSVP.resolve() async moves us into the promise land.
return Promise.resolve(null, this.promiseLabel('Start transition'))
.then(resolveOneRouteInfo, null, this.promiseLabel('Resolve route'))
.catch(handleError, this.promiseLabel('Handle error'));

function innerShouldContinue() {
return Promise.resolve(
shouldContinue(),
currentState.promiseLabel('Check if should continue')
).catch(function (reason) {
// We distinguish between errors that occurred
// during resolution (e.g. before"Model/model/afterModel),
// and aborts due to a rejecting promise from shouldContinue().
wasAborted = true;
return Promise.reject(reason);
}, currentState.promiseLabel('Handle abort'));
}

function handleError(error: Error) {
// This is the only possible
// reject value of TransitionState#resolve
let routeInfos = currentState.routeInfos;
let errorHandlerIndex =
transition.resolveIndex >= routeInfos.length
? routeInfos.length - 1
: transition.resolveIndex;
return Promise.reject(
new TransitionError(
error,
currentState.routeInfos[errorHandlerIndex].route!,
wasAborted,
currentState
)
);
}

function proceed(resolvedRouteInfo: InternalRouteInfo<T>): Promise<InternalRouteInfo<T>> {
let wasAlreadyResolved = currentState.routeInfos[transition.resolveIndex].isResolved;

// Swap the previously unresolved routeInfo with
// the resolved routeInfo
currentState.routeInfos[transition.resolveIndex++] = resolvedRouteInfo;

if (!wasAlreadyResolved) {
// Call the redirect hook. The reason we call it here
// vs. afterModel is so that redirects into child
// routes don't re-run the model hooks for this
// already-resolved route.
let { route } = resolvedRouteInfo;
if (route !== undefined) {
if (route.redirect) {
route.redirect(resolvedRouteInfo.context as Dict<unknown>, transition);
}
}
}

// Proceed after ensuring that the redirect hook
// didn't abort this transition by transitioning elsewhere.
return innerShouldContinue().then(
resolveOneRouteInfo,
.then(
resolveOneRouteInfo.bind(null, this, transition),
null,
currentState.promiseLabel('Resolve route')
);
}

function resolveOneRouteInfo(): TransitionState<T> | Promise<any> {
if (transition.resolveIndex === currentState.routeInfos.length) {
// This is is the only possible
// fulfill value of TransitionState#resolve
return currentState;
}

let routeInfo = currentState.routeInfos[transition.resolveIndex];

return routeInfo
.resolve(innerShouldContinue, transition)
.then(proceed, null, currentState.promiseLabel('Proceed'));
}
this.promiseLabel('Resolve route')
)
.catch(handleError.bind(null, this, transition), this.promiseLabel('Handle error'))
.then(() => this);
}
}

16 changes: 5 additions & 11 deletions lib/router/transition.ts
Original file line number Diff line number Diff line change
@@ -169,17 +169,11 @@ export default class Transition<T extends Route> implements Partial<Promise<T>>
}

this.sequence = router.currentSequence++;
this.promise = state
.resolve(() => {
if (this.isAborted) {
return Promise.reject(false, promiseLabel('Transition aborted - reject'));
}

return Promise.resolve(true);
}, this)
.catch((result: TransitionError) => {
return Promise.reject(this.router.transitionDidError(result, this));
}, promiseLabel('Handle Abort'));
this.promise = state.resolve(this).catch((result: TransitionError) => {
let error = this.router.transitionDidError(result, this);

throw error;
}, promiseLabel('Handle Abort'));
} else {
this.promise = Promise.resolve(this[STATE_SYMBOL]!);
this[PARAMS_SYMBOL] = {};
91 changes: 40 additions & 51 deletions tests/route_info_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Transition } from 'router';
import { Dict } from 'router/core';
import RouteInfo, {
import {
ResolvedRouteInfo,
Route,
toReadOnlyRouteInfo,
@@ -9,24 +9,20 @@ import RouteInfo, {
} from 'router/route-info';
import InternalTransition from 'router/transition';
import URLTransitionIntent from 'router/transition-intent/url-transition-intent';
import { reject, resolve } from 'rsvp';
import { resolve } from 'rsvp';
import { createHandler, createHandlerInfo, module, test, TestRouter } from './test_helpers';

function noop() {
return resolve(true);
}

module('RouteInfo');

test('ResolvedRouteInfo resolve to themselves', function (assert) {
test('ResolvedRouteInfo resolve to themselves', async function (assert) {
let router = new TestRouter();
let routeInfo = new ResolvedRouteInfo(router, 'foo', [], {}, createHandler('empty'));
let intent = new URLTransitionIntent(router, 'foo');
routeInfo
.resolve(() => false, new InternalTransition(router, intent, undefined))
.then(function (resolvedRouteInfo) {
assert.equal(routeInfo, resolvedRouteInfo);
});

let transition = new InternalTransition(router, intent, undefined);

const resolvedRouteInfo = await routeInfo.resolve(transition);
assert.equal(routeInfo, resolvedRouteInfo);
});

test('UnresolvedRouteInfoByParam defaults params to {}', function (assert) {
@@ -38,36 +34,33 @@ test('UnresolvedRouteInfoByParam defaults params to {}', function (assert) {
assert.deepEqual(routeInfo2.params, { foo: 5 });
});

test('RouteInfo can be aborted mid-resolve', function (assert) {
assert.expect(2);
test('RouteInfo can be aborted mid-resolve', async function (assert) {
assert.expect(1);

let routeInfo = createHandlerInfo('stub');

function abortResolve() {
assert.ok(true, 'abort was called');
return reject('LOL');
let transition = {} as Transition;
transition.isAborted = true;
try {
await routeInfo.resolve(transition);
assert.ok(false, 'unreachable');
} catch (e) {
assert.equal(e, 'LOL');
}

routeInfo.resolve(abortResolve, {} as Transition).catch(function (error: Error) {
assert.equal(error, 'LOL');
});
});

test('RouteInfo#resolve resolves with a ResolvedRouteInfo', function (assert) {
test('RouteInfo#resolve resolves with a ResolvedRouteInfo', async function (assert) {
assert.expect(1);

let routeInfo = createHandlerInfo('stub');
routeInfo
.resolve(() => false, {} as Transition)
.then(function (resolvedRouteInfo: RouteInfo<Route>) {
assert.ok(resolvedRouteInfo instanceof ResolvedRouteInfo);
});
let resolvedRouteInfo = await routeInfo.resolve({} as Transition);
assert.ok(resolvedRouteInfo instanceof ResolvedRouteInfo);
});

test('RouteInfo#resolve runs beforeModel hook on handler', function (assert) {
test('RouteInfo#resolve runs beforeModel hook on handler', async function (assert) {
assert.expect(1);

let transition = {};
let transition = {} as Transition;

let routeInfo = createHandlerInfo('stub', {
route: createHandler('stub', {
@@ -81,62 +74,59 @@ test('RouteInfo#resolve runs beforeModel hook on handler', function (assert) {
}),
});

routeInfo.resolve(noop, transition as Transition);
await routeInfo.resolve(transition);
});

test('RouteInfo#resolve runs getModel hook', function (assert) {
test('RouteInfo#resolve runs getModel hook', async function (assert) {
assert.expect(1);

let transition = {};
let transition = {} as Transition;

let routeInfo = createHandlerInfo('stub', {
getModel(payload: Dict<unknown>) {
assert.equal(payload, transition);
},
});

routeInfo.resolve(noop, transition as Transition);
await routeInfo.resolve(transition);
});

test('RouteInfo#resolve runs afterModel hook on handler', function (assert) {
test('RouteInfo#resolve runs afterModel hook on handler', async function (assert) {
assert.expect(3);

let transition = {};
let transition = {} as Transition;
let model = {};

let routeInfo = createHandlerInfo('foo', {
route: createHandler('foo', {
afterModel: function (resolvedModel: Dict<unknown>, payload: Dict<unknown>) {
afterModel(resolvedModel: Dict<unknown>, payload: Dict<unknown>) {
assert.equal(resolvedModel, model, 'afterModel receives the value resolved by model');
assert.equal(payload, transition);
return resolve(123); // 123 should get ignored
},
}),
getModel: function () {
getModel() {
return resolve(model);
},
});

routeInfo
.resolve(noop, transition as Transition)
.then(function (resolvedRouteInfo: RouteInfo<Route>) {
assert.equal(resolvedRouteInfo.context, model, 'RouteInfo resolved with correct model');
});
let resolvedRouteInfo = await routeInfo.resolve(transition);
assert.equal(resolvedRouteInfo.context, model, 'RouteInfo resolved with correct model');
});

test('UnresolvedRouteInfoByParam gets its model hook called', function (assert) {
test('UnresolvedRouteInfoByParam gets its model hook called', async function (assert) {
assert.expect(2);
let router = new TestRouter();

let transition = {};
let transition = {} as Transition;

let routeInfo = new UnresolvedRouteInfoByParam(
router,
'empty',
[],
{ first_name: 'Alex', last_name: 'Matchnerd' },
createHandler('h', {
model: function (params: Dict<unknown>, payload: Dict<unknown>) {
model(params: Dict<unknown>, payload: Dict<unknown>) {
assert.equal(payload, transition);
assert.deepEqual(params, {
first_name: 'Alex',
@@ -146,10 +136,10 @@ test('UnresolvedRouteInfoByParam gets its model hook called', function (assert)
})
);

routeInfo.resolve(noop, transition as Transition);
await routeInfo.resolve(transition);
});

test('UnresolvedRouteInfoByObject does NOT get its model hook called', function (assert) {
test('UnresolvedRouteInfoByObject does NOT get its model hook called', async function (assert) {
assert.expect(1);

class TestRouteInfo extends UnresolvedRouteInfoByObject<Route> {
@@ -173,10 +163,9 @@ test('UnresolvedRouteInfoByObject does NOT get its model hook called', function
resolve({ name: 'dorkletons' })
);

routeInfo.resolve(noop, {} as Transition).then(function (resolvedRouteInfo: RouteInfo<Route>) {
// @ts-ignore
assert.equal(resolvedRouteInfo.context!.name, 'dorkletons');
});
let resolvedRouteInfo = await routeInfo.resolve({} as Transition);
// @ts-ignore
assert.equal(resolvedRouteInfo.context!.name, 'dorkletons');
});

test('RouteInfo.find', function (assert) {
44 changes: 12 additions & 32 deletions tests/transition_state_test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { Transition } from 'router';
import { Dict } from 'router/core';
import {
Continuation,
Route,
UnresolvedRouteInfoByObject,
UnresolvedRouteInfoByParam,
} from 'router/route-info';
import { Route, UnresolvedRouteInfoByObject, UnresolvedRouteInfoByParam } from 'router/route-info';
import TransitionState, { TransitionError } from 'router/transition-state';
import { Promise, reject, resolve } from 'rsvp';
import { Promise, resolve } from 'rsvp';
import {
createHandler,
createHandlerInfo,
@@ -25,7 +20,7 @@ test('it starts off with default state', function (assert) {
});

test("#resolve delegates to handleInfo objects' resolve()", function (assert) {
assert.expect(7);
assert.expect(3);

let state = new TransitionState();

@@ -35,43 +30,34 @@ test("#resolve delegates to handleInfo objects' resolve()", function (assert) {

state.routeInfos = [
createHandlerInfo('one', {
resolve: function (shouldContinue: Continuation) {
resolve: function () {
++counter;
assert.equal(counter, 1);
shouldContinue();
return resolve(resolvedHandlerInfos[0]);
},
}),
createHandlerInfo('two', {
resolve: function (shouldContinue: Continuation) {
resolve: function () {
++counter;
assert.equal(counter, 2);
shouldContinue();
return resolve(resolvedHandlerInfos[1]);
},
}),
];

function keepGoing() {
assert.ok(true, 'continuation function was called');
return Promise.resolve(false);
}

state.resolve(keepGoing, {} as Transition).then(function (result: TransitionState<Route>) {
state.resolve({} as Transition).then(function (result: TransitionState<Route>) {
assert.deepEqual(result.routeInfos, resolvedHandlerInfos);
});
});

test('State resolution can be halted', function (assert) {
assert.expect(2);
assert.expect(1);

let state = new TransitionState();

state.routeInfos = [
createHandlerInfo('one', {
resolve: function (shouldContinue: Continuation) {
return shouldContinue();
},
resolve: function () {},
}),
createHandlerInfo('two', {
resolve: function () {
@@ -80,12 +66,10 @@ test('State resolution can be halted', function (assert) {
}),
];

function keepGoing() {
return reject('NOPE');
}
let fakeTransition = {} as Transition;
fakeTransition.isAborted = true;

state.resolve(keepGoing, {} as Transition).catch(function (reason: TransitionError) {
assert.equal(reason.error, 'NOPE');
state.resolve(fakeTransition).catch(function (reason: TransitionError) {
assert.ok(reason.wasAborted, 'state resolution was correctly marked as aborted');
});

@@ -118,12 +102,8 @@ test('Integration w/ HandlerInfos', function (assert) {
new UnresolvedRouteInfoByObject(router, 'bar', ['bar_id'], resolve(barModel)),
];

function noop() {
return Promise.resolve(false);
}

state
.resolve(noop, transition as Transition)
.resolve(transition as Transition)
.then(function (result: TransitionState<Route>) {
let models = [];
for (let i = 0; i < result.routeInfos.length; i++) {