Skip to content

Commit 290c3be

Browse files
committed
feat: refactor navigation to comply with vuejs/rfcs#150
BREAKING CHANGE: This follows the RFC at vuejs/rfcs#150 Summary: `router.afterEach` and `router.onError` are now the global equivalent of `router.push`/`router.replace` as well as navigation through the interface (`history.go()`). A navigation only rejects if there was an unexpected error. A navigation failure will still resolve the promise returned by `router.push` and be exposed as the resolved value.
1 parent 0e870d7 commit 290c3be

10 files changed

+453
-153
lines changed

__tests__/errors.spec.ts

+287-26
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
1+
import fakePromise from 'faked-promise'
12
import { createRouter as newRouter, createMemoryHistory } from '../src'
2-
import { ErrorTypes } from '../src/errors'
3-
import { components } from './utils'
4-
import { RouteRecordRaw } from '../src/types'
3+
import { NavigationFailure, NavigationFailureType } from '../src/errors'
4+
import { components, tick } from './utils'
5+
import { RouteRecordRaw, NavigationGuard, RouteLocationRaw } from '../src/types'
56

67
const routes: RouteRecordRaw[] = [
78
{ path: '/', component: components.Home },
9+
{ path: '/redirect', redirect: '/' },
810
{ path: '/foo', component: components.Foo, name: 'Foo' },
9-
{ path: '/to-foo', redirect: '/foo' },
10-
{ path: '/to-foo-named', redirect: { name: 'Foo' } },
11-
{ path: '/to-foo2', redirect: '/to-foo' },
12-
{ path: '/p/:p', name: 'Param', component: components.Bar },
13-
{ path: '/to-p/:p', redirect: to => `/p/${to.params.p}` },
14-
{
15-
path: '/inc-query-hash',
16-
redirect: to => ({
17-
name: 'Foo',
18-
query: { n: to.query.n + '-2' },
19-
hash: to.hash + '-2',
20-
}),
21-
},
2211
]
2312

2413
const onError = jest.fn()
14+
const afterEach = jest.fn()
2515
function createRouter() {
2616
const history = createMemoryHistory()
2717
const router = newRouter({
@@ -30,27 +20,298 @@ function createRouter() {
3020
})
3121

3222
router.onError(onError)
23+
router.afterEach(afterEach)
3324
return { router, history }
3425
}
3526

36-
describe('Errors', () => {
27+
describe('Errors & Navigation failures', () => {
3728
beforeEach(() => {
3829
onError.mockReset()
30+
afterEach.mockReset()
3931
})
4032

41-
it('triggers onError when navigation is aborted', async () => {
33+
it('next(false) triggers afterEach', async () => {
34+
await testNavigation(
35+
false,
36+
expect.objectContaining({
37+
type: NavigationFailureType.aborted,
38+
})
39+
)
40+
})
41+
42+
it('next("/location") triggers afterEach', async () => {
43+
await testNavigation(
44+
((to, from, next) => {
45+
if (to.path === '/location') next()
46+
else next('/location')
47+
}) as NavigationGuard,
48+
undefined
49+
)
50+
})
51+
52+
it('redirect triggers afterEach', async () => {
53+
await testNavigation(undefined, undefined, '/redirect')
54+
})
55+
56+
it('next() triggers afterEach', async () => {
57+
await testNavigation(undefined, undefined)
58+
})
59+
60+
it('next(true) triggers afterEach', async () => {
61+
await testNavigation(true, undefined)
62+
})
63+
64+
it('triggers afterEach if a new navigation happens', async () => {
4265
const { router } = createRouter()
66+
const [promise, resolve] = fakePromise()
4367
router.beforeEach((to, from, next) => {
44-
next(false)
68+
// let it hang otherwise
69+
if (to.path === '/') next()
70+
else promise.then(() => next())
4571
})
4672

47-
try {
48-
await router.push('/foo')
49-
} catch (err) {
50-
expect(err.type).toBe(ErrorTypes.NAVIGATION_ABORTED)
51-
}
52-
expect(onError).toHaveBeenCalledWith(
53-
expect.objectContaining({ type: ErrorTypes.NAVIGATION_ABORTED })
73+
let from = router.currentRoute.value
74+
75+
// should hang
76+
let navigationPromise = router.push('/foo')
77+
78+
await expect(router.push('/')).resolves.toEqual(undefined)
79+
expect(afterEach).toHaveBeenCalledTimes(1)
80+
expect(onError).toHaveBeenCalledTimes(0)
81+
82+
resolve()
83+
await navigationPromise
84+
expect(afterEach).toHaveBeenCalledTimes(2)
85+
expect(onError).toHaveBeenCalledTimes(0)
86+
87+
expect(afterEach).toHaveBeenLastCalledWith(
88+
expect.objectContaining({ path: '/foo' }),
89+
from,
90+
expect.objectContaining({ type: NavigationFailureType.cancelled })
5491
)
5592
})
93+
94+
it('next(new Error()) triggers onError', async () => {
95+
let error = new Error()
96+
await testError(error, error)
97+
})
98+
99+
it('triggers onError with thrown errors', async () => {
100+
let error = new Error()
101+
await testError(() => {
102+
throw error
103+
}, error)
104+
})
105+
106+
it('triggers onError with rejected promises', async () => {
107+
let error = new Error()
108+
await testError(async () => {
109+
throw error
110+
}, error)
111+
})
112+
113+
describe('history navigation', () => {
114+
it('triggers afterEach with history.back', async () => {
115+
const { router, history } = createRouter()
116+
117+
await router.push('/')
118+
await router.push('/foo')
119+
120+
afterEach.mockReset()
121+
onError.mockReset()
122+
123+
const [promise, resolve] = fakePromise()
124+
router.beforeEach((to, from, next) => {
125+
// let it hang otherwise
126+
if (to.path === '/') next()
127+
else promise.then(() => next())
128+
})
129+
130+
let from = router.currentRoute.value
131+
132+
// should hang
133+
let navigationPromise = router.push('/bar')
134+
135+
// goes from /foo to /
136+
history.go(-1)
137+
138+
await tick()
139+
140+
expect(afterEach).toHaveBeenCalledTimes(1)
141+
expect(onError).toHaveBeenCalledTimes(0)
142+
expect(afterEach).toHaveBeenLastCalledWith(
143+
expect.objectContaining({ path: '/' }),
144+
from,
145+
undefined
146+
)
147+
148+
resolve()
149+
await expect(navigationPromise).resolves.toEqual(
150+
expect.objectContaining({ type: NavigationFailureType.cancelled })
151+
)
152+
153+
expect(afterEach).toHaveBeenCalledTimes(2)
154+
expect(onError).toHaveBeenCalledTimes(0)
155+
156+
expect(afterEach).toHaveBeenLastCalledWith(
157+
expect.objectContaining({ path: '/bar' }),
158+
from,
159+
expect.objectContaining({ type: NavigationFailureType.cancelled })
160+
)
161+
})
162+
163+
it('next(false) triggers afterEach with history.back', async () => {
164+
await testHistoryNavigation(
165+
false,
166+
expect.objectContaining({ type: NavigationFailureType.aborted })
167+
)
168+
})
169+
170+
it('next("/location") triggers afterEach with history.back', async () => {
171+
await testHistoryNavigation(
172+
((to, from, next) => {
173+
if (to.path === '/location') next()
174+
else next('/location')
175+
}) as NavigationGuard,
176+
undefined
177+
)
178+
})
179+
180+
it('next() triggers afterEach with history.back', async () => {
181+
await testHistoryNavigation(undefined, undefined)
182+
})
183+
184+
it('next(true) triggers afterEach with history.back', async () => {
185+
await testHistoryNavigation(true, undefined)
186+
})
187+
188+
it('next(new Error()) triggers onError with history.back', async () => {
189+
let error = new Error()
190+
await testHistoryError(error, error)
191+
})
192+
193+
it('triggers onError with thrown errors with history.back', async () => {
194+
let error = new Error()
195+
await testHistoryError(() => {
196+
throw error
197+
}, error)
198+
})
199+
200+
it('triggers onError with rejected promises with history.back', async () => {
201+
let error = new Error()
202+
await testHistoryError(async () => {
203+
throw error
204+
}, error)
205+
})
206+
})
56207
})
208+
209+
async function testError(
210+
nextArgument: any | NavigationGuard,
211+
expectedError: Error | void = undefined,
212+
to: RouteLocationRaw = '/foo'
213+
) {
214+
const { router } = createRouter()
215+
router.beforeEach(
216+
typeof nextArgument === 'function'
217+
? nextArgument
218+
: (to, from, next) => {
219+
next(nextArgument)
220+
}
221+
)
222+
223+
await expect(router.push(to)).rejects.toEqual(expectedError)
224+
225+
expect(afterEach).toHaveBeenCalledTimes(0)
226+
expect(onError).toHaveBeenCalledTimes(1)
227+
228+
expect(onError).toHaveBeenCalledWith(expectedError)
229+
}
230+
231+
async function testNavigation(
232+
nextArgument: any | NavigationGuard,
233+
expectedFailure: NavigationFailure | void = undefined,
234+
to: RouteLocationRaw = '/foo'
235+
) {
236+
const { router } = createRouter()
237+
router.beforeEach(
238+
typeof nextArgument === 'function'
239+
? nextArgument
240+
: (to, from, next) => {
241+
next(nextArgument)
242+
}
243+
)
244+
245+
await expect(router.push(to)).resolves.toEqual(expectedFailure)
246+
247+
expect(afterEach).toHaveBeenCalledTimes(1)
248+
expect(onError).toHaveBeenCalledTimes(0)
249+
250+
expect(afterEach).toHaveBeenCalledWith(
251+
expect.any(Object),
252+
expect.any(Object),
253+
expectedFailure
254+
)
255+
}
256+
257+
async function testHistoryNavigation(
258+
nextArgument: any | NavigationGuard,
259+
expectedFailure: NavigationFailure | void = undefined,
260+
to: RouteLocationRaw = '/foo'
261+
) {
262+
const { router, history } = createRouter()
263+
await router.push(to)
264+
265+
router.beforeEach(
266+
typeof nextArgument === 'function'
267+
? nextArgument
268+
: (to, from, next) => {
269+
next(nextArgument)
270+
}
271+
)
272+
273+
afterEach.mockReset()
274+
onError.mockReset()
275+
276+
history.go(-1)
277+
278+
await tick()
279+
280+
expect(afterEach).toHaveBeenCalledTimes(1)
281+
expect(onError).toHaveBeenCalledTimes(0)
282+
283+
expect(afterEach).toHaveBeenCalledWith(
284+
expect.any(Object),
285+
expect.any(Object),
286+
expectedFailure
287+
)
288+
}
289+
290+
async function testHistoryError(
291+
nextArgument: any | NavigationGuard,
292+
expectedError: Error | void = undefined,
293+
to: RouteLocationRaw = '/foo'
294+
) {
295+
const { router, history } = createRouter()
296+
await router.push(to)
297+
298+
router.beforeEach(
299+
typeof nextArgument === 'function'
300+
? nextArgument
301+
: (to, from, next) => {
302+
next(nextArgument)
303+
}
304+
)
305+
306+
afterEach.mockReset()
307+
onError.mockReset()
308+
309+
history.go(-1)
310+
311+
await tick()
312+
313+
expect(afterEach).toHaveBeenCalledTimes(0)
314+
expect(onError).toHaveBeenCalledTimes(1)
315+
316+
expect(onError).toHaveBeenCalledWith(expectedError)
317+
}

__tests__/guards/global-after.spec.ts

+7-15
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ describe('router.afterEach', () => {
3131
expect(spy).toHaveBeenCalledTimes(1)
3232
expect(spy).toHaveBeenCalledWith(
3333
expect.objectContaining({ fullPath: '/foo' }),
34-
expect.objectContaining({ fullPath: '/' })
34+
expect.objectContaining({ fullPath: '/' }),
35+
undefined
3536
)
3637
})
3738

@@ -44,7 +45,7 @@ describe('router.afterEach', () => {
4445
expect(spy).not.toHaveBeenCalled()
4546
})
4647

47-
it('calls afterEach guards on push', async () => {
48+
it('calls afterEach guards on multiple push', async () => {
4849
const spy = jest.fn()
4950
const router = createRouter({ routes })
5051
await router.push('/nested')
@@ -53,24 +54,15 @@ describe('router.afterEach', () => {
5354
expect(spy).toHaveBeenCalledTimes(1)
5455
expect(spy).toHaveBeenLastCalledWith(
5556
expect.objectContaining({ name: 'nested-home' }),
56-
expect.objectContaining({ name: 'nested-default' })
57+
expect.objectContaining({ name: 'nested-default' }),
58+
undefined
5759
)
5860
await router.push('/nested')
5961
expect(spy).toHaveBeenLastCalledWith(
6062
expect.objectContaining({ name: 'nested-default' }),
61-
expect.objectContaining({ name: 'nested-home' })
63+
expect.objectContaining({ name: 'nested-home' }),
64+
undefined
6265
)
6366
expect(spy).toHaveBeenCalledTimes(2)
6467
})
65-
66-
it('does not call afterEach if navigation is cancelled', async () => {
67-
const spy = jest.fn()
68-
const router = createRouter({ routes })
69-
router.afterEach(spy)
70-
router.beforeEach((to, from, next) => {
71-
next(false) // cancel the navigation
72-
})
73-
await router.push('/foo').catch(err => {}) // ignore the error
74-
expect(spy).not.toHaveBeenCalled()
75-
})
7668
})

0 commit comments

Comments
 (0)