const routes = [
{ path: '/', component: Home },
{ path: '/foo', component: Foo },
+ { path: '/other', component: Foo },
+ { path: '/n/:i', name: 'n', component: Home },
]
describe('router.beforeEach', () => {
expect(spy).not.toHaveBeenCalled()
})
+ it('can redirect to a different location', async () => {
+ const spy = jest.fn()
+ const router = createRouter({ routes })
+ await router.push('/foo')
+ spy.mockImplementation((to, from, next) => {
+ // only allow going to /other
+ if (to.fullPath !== '/other') next('/other')
+ else next()
+ })
+ router.beforeEach(spy)
+ expect(spy).not.toHaveBeenCalled()
+ await router[navigationMethod]('/')
+ expect(spy).toHaveBeenCalledTimes(2)
+ // called before redirect
+ expect(spy).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ path: '/' }),
+ expect.objectContaining({ path: '/foo' }),
+ expect.any(Function)
+ )
+ expect(spy).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ path: '/other' }),
+ expect.objectContaining({ path: '/foo' }),
+ expect.any(Function)
+ )
+ expect(router.currentRoute.fullPath).toBe('/other')
+ })
+
+ async function assertRedirect(redirectFn) {
+ const spy = jest.fn()
+ const router = createRouter({ routes })
+ await router.push('/')
+ spy.mockImplementation((to, from, next) => {
+ // only allow going to /other
+ const i = Number(to.params.i)
+ if (i >= 3) next()
+ else next(redirectFn(i + 1))
+ })
+ router.beforeEach(spy)
+ expect(spy).not.toHaveBeenCalled()
+ await router[navigationMethod]('/n/0')
+ expect(spy).toHaveBeenCalledTimes(4)
+ expect(router.currentRoute.fullPath).toBe('/n/3')
+ }
+
+ it('can redirect multiple times with string redirect', async () => {
+ await assertRedirect(i => '/n/' + i)
+ })
+
+ it('can redirect multiple times with path object', async () => {
+ await assertRedirect(i => ({ path: '/n/' + i }))
+ })
+
+ it('can redirect multiple times with named route', async () => {
+ await assertRedirect(i => ({ name: 'n', params: { i } }))
+ })
+
+ it('is called when changing params', async () => {
+ const spy = jest.fn()
+ const router = createRouter({ routes: [...routes] })
+ await router.push('/n/2')
+ spy.mockImplementation(noGuard)
+ router.beforeEach(spy)
+ spy.mockImplementationOnce(noGuard)
+ await router[navigationMethod]('/n/1')
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
+
+ it('is not called with same params', async () => {
+ const spy = jest.fn()
+ const router = createRouter({ routes: [...routes] })
+ await router.push('/n/2')
+ spy.mockImplementation(noGuard)
+ router.beforeEach(spy)
+ spy.mockImplementationOnce(noGuard)
+ await router[navigationMethod]('/n/2')
+ expect(spy).not.toHaveBeenCalled()
+ })
+
it('waits before navigating', async () => {
const [promise, resolve] = fakePromise()
const router = createRouter({ routes })
+import { RouteLocationNormalized, RouteLocation } from './types'
+
export class NoRouteMatchError extends Error {
constructor(currentLocation: any, location: any) {
super('No match for ' + JSON.stringify({ ...currentLocation, ...location }))
Object.setPrototypeOf(this, new.target.prototype)
}
}
+
+/**
+ * Error used when rejecting a navigation because of a redirection. Contains
+ * information about where we where trying to go and where we are going instead
+ */
+export class RedirectError extends Error {
+ from: RouteLocationNormalized
+ to: RouteLocation
+ constructor(from: RouteLocationNormalized, to: RouteLocation) {
+ super(
+ `Redirected from "${from.fullPath}" to "${stringifyRoute(
+ to
+ )}" via a navigation guard`
+ )
+ Object.setPrototypeOf(this, new.target.prototype)
+
+ this.from = from
+ this.to = to
+ }
+}
+
+function stringifyRoute(to: RouteLocation): string {
+ if (typeof to === 'string') return to
+ if ('path' in to) return to.path
+ return 'TODO'
+}
NavigationGuard,
TODO,
PostNavigationGuard,
+ Lazy,
} from './types/index'
-import { guardToPromiseFn, last, extractComponentsGuards } from './utils'
+import { guardToPromiseFn, extractComponentsGuards } from './utils'
+import { RedirectError } from './errors'
export interface RouterOptions {
history: BaseHistory
* guards first
* @param to where to go
*/
- async push(to: RouteLocation) {
+ async push(to: RouteLocation): Promise<RouteLocationNormalized> {
let url: HistoryLocationNormalized
let location: MatcherLocationNormalized
if (typeof to === 'string' || 'path' in to) {
})
}
+ // TODO: should we throw an error as the navigation was aborted
+ if (this.currentRoute.fullPath === url.fullPath) return this.currentRoute
+
const toLocation: RouteLocationNormalized = { ...url, ...location }
// trigger all guards, throw if navigation is rejected
- await this.navigate(toLocation, this.currentRoute)
+ try {
+ await this.navigate(toLocation, this.currentRoute)
+ } catch (error) {
+ if (error instanceof RedirectError) {
+ // TODO: setup redirect stack
+ return this.push(error.to)
+ } else {
+ throw error
+ }
+ }
// change URL
if (to.replace === true) this.history.replace(url)
// navigation is confirmed, call afterGuards
for (const guard of this.afterGuards) guard(toLocation, from)
+
+ return this.currentRoute
}
/**
return this.push({ ...location, replace: true })
}
+ /**
+ * Runs a guard queue and handles redirects, rejections
+ * @param guards Array of guards converted to functions that return a promise
+ * @returns {boolean} true if the navigation should be cancelled false otherwise
+ */
+ private async runGuardQueue(guards: Lazy<any>[]): Promise<void> {
+ for (const guard of guards) {
+ await guard()
+ }
+ }
+
private async navigate(
to: RouteLocationNormalized,
from: RouteLocationNormalized
): Promise<TODO> {
// TODO: Will probably need to be some kind of queue in the future that allows to remove
// elements and other stuff
- let guards: Array<() => Promise<any>>
+ let guards: Lazy<any>[]
// TODO: is it okay to resolve all matched component or should we do it in order
// TODO: use only components that we are leaving (children)
)
// run the queue of per route beforeEnter guards
- for (const guard of guards) {
- await guard()
- }
+ await this.runGuardQueue(guards)
// check global guards beforeEach
// avoid if we are not changing route
// TODO: trigger on child navigation
- // TODO: should we completely avoid a navigation towards the same route?
- if (last(to.matched) !== last(from.matched)) {
- guards = []
- for (const guard of this.beforeGuards) {
- guards.push(guardToPromiseFn(guard, to, from))
- }
-
- // console.log('Guarding against', guards.length, 'guards')
- for (const guard of guards) {
- await guard()
- }
+ guards = []
+ for (const guard of this.beforeGuards) {
+ guards.push(guardToPromiseFn(guard, to, from))
}
+ // console.log('Guarding against', guards.length, 'guards')
+ await this.runGuardQueue(guards)
+
// check in components beforeRouteUpdate
guards = await extractComponentsGuards(
to.matched.filter(record => from.matched.indexOf(record) > -1),
)
// run the queue of per route beforeEnter guards
- for (const guard of guards) {
- await guard()
- }
+ await this.runGuardQueue(guards)
// check the route beforeEnter
// TODO: check children. Should we also check reused routes guards
}
// run the queue of per route beforeEnter guards
- for (const guard of guards) {
- await guard()
- }
+ await this.runGuardQueue(guards)
// check in-component beforeRouteEnter
// TODO: is it okay to resolve all matched component or should we do it in order
)
// run the queue of per route beforeEnter guards
- for (const guard of guards) {
- await guard()
- }
+ await this.runGuardQueue(guards)
}
/**
import { HistoryQuery } from '../history/base'
-type Lazy<T> = () => Promise<T>
+export type Lazy<T> = () => Promise<T>
export type TODO = any
} from '../types'
import { isRouteLocation } from './index'
+import { RedirectError } from '../errors'
export function guardToPromiseFn(
guard: NavigationGuard,
if (valid === false) reject(new Error('Aborted'))
else if (isRouteLocation(valid)) {
// TODO: redirect
+ reject(new RedirectError(to, valid))
} else resolve()
}