Skip to content

Commit 5a3a898

Browse files
posvanicolas-ttrickstivalsnoozbuster
authored
Redesigning Navigation failures and global handlers (#150)
* initial * Apply suggestions from code review Co-Authored-By: Nicolas Turlais <[email protected]> Co-Authored-By: Patrick <[email protected]> * clearer distinction with `redirect` * Apply suggestions from code review Co-Authored-By: Alex Van Liew <[email protected]> * add adoption strategy Co-authored-by: Nicolas Turlais <[email protected]> Co-authored-by: Patrick <[email protected]> Co-authored-by: Alex Van Liew <[email protected]>
1 parent 5cfab4f commit 5a3a898

File tree

1 file changed

+174
-0
lines changed

1 file changed

+174
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
- Start Date: 2020-03-26
2+
- Target Major Version: Vue Router v4
3+
- Reference Issues: https://github.com/vuejs/vue-router/issues/2833, https://github.com/vuejs/vue-router/pull/3047, https://github.com/vuejs/vue-router/issues/2932, https://github.com/vuejs/vue-router/issues/2881
4+
- Implementation PR:
5+
6+
# Summary
7+
8+
- Explicitly define what a navigation failure is, and how and where we can catch them.
9+
- Change when the Promised-based `router.push`(and `router.replace` by extension) resolves and rejects.
10+
- Make `router.push` consistent with `router.afterEach` and `router.onError`.
11+
12+
# Basic example
13+
14+
## `router.push`
15+
16+
If there is an unhandled error or an error is passed to `next`:
17+
18+
```js
19+
// any other navigation guard
20+
router.beforeEach((to, from, next) => {
21+
next(new Error())
22+
// or
23+
throw new Error()
24+
// or
25+
return Promise.reject(new Error())
26+
})
27+
```
28+
29+
Then the promise returned by `router.push` rejects:
30+
31+
```js
32+
router.push('/url').catch(err => {
33+
// ...
34+
})
35+
```
36+
37+
In **all** other cases, the promise is resolved. We can know if the navigation failed or not by checking the resolved value:
38+
39+
```js
40+
router.push('/dashboard').then(failure => {
41+
if (failure) {
42+
failure instanceof Error // true
43+
failure.type // NavigationFailure.canceled
44+
}
45+
})
46+
```
47+
48+
## `router.afterEach`
49+
50+
It's the global equivalent of `router.push().then()`
51+
52+
## `router.onError`
53+
54+
It's the global equivalent of `router.push().catch()`
55+
56+
# Motivation
57+
58+
The current behavior of Vue Router regarding the promise returned by `push` is inconsistent with `router.afterEach` and `router.onError`. Ideally, we should be able to catch all succeeded and failed navigations globally and locally but we can only do it locally.
59+
60+
- `onError` is only triggered on thrown errors and `next(new Error())`
61+
- `afterEach` is only called if there is a navigation
62+
- `redirect` should behave the same as `next('/url')` in a Navigation guard when it comes to the outcome of `router.push` and calls of `router.afterEach`/`router.onError`. The only difference being that a `redirect` would only trigger leave guards and other before guards for the redirected location but not the original one
63+
64+
The differences between the Promise resolution/rejection vs `router.afterEach` and `router.onError` are inconsistent and confusing.
65+
66+
# Detailed design
67+
68+
One of the main points is to be able to consistently handle failed navigations globally and locally:
69+
70+
- Failed Navigation:
71+
- Triggers `router.afterEach`
72+
- Resolves the `Promise` returned by `router.push`
73+
- Uncaught Errors, `next(new Error())`
74+
- Triggers `router.onError`
75+
- Rejects the `Promise` returned by `router.push`
76+
77+
It's important to note there is no overlap in these two groups: if there is an unhandled error in a Navigation Guard, it will trigger `router.onError` as well as rejecting the `Promise` returned by `router.push` but **will not trigger** `router.afterEach`. Cancelling a navigation with `next(false)` will not trigger `router.onError` but will trigger `router.afterEach`
78+
79+
## Changes to the Promise resolution and rejection
80+
81+
Navigation methods like `push` return a Promise, this promise **resolves** once the navigation succeeds **or** fail. It **rejects** only if there was an unhandled error. If it rejects, it will also trigger `router.onError`.
82+
83+
To differentiate a Navigation that succeeded from one that failed, the `Promise` returned by `push` will resolve to either `undefined` or a `NavigationFailure`:
84+
85+
```js
86+
import { NavigationFailureType } from 'vue-router'
87+
88+
router.push('/').then(failure => {
89+
if (failure) {
90+
// Having an Error instance allows us to have a Stacktrace and trace back
91+
// where the navigation was cancelled. This will, in many cases, lead to a
92+
// Navigation Guard and the corresponding `next()` call that cancelled the
93+
// navigation
94+
failure instanceof Error // true
95+
if (failure.type === NavigationFailureType.canceled) {
96+
// ...
97+
}
98+
}
99+
})
100+
101+
// using async/await
102+
let failure = await router.push('/')
103+
if (failure) {
104+
// ...
105+
}
106+
```
107+
108+
By not rejecting the `Promise` when the navigation fails, we are avoiding _Uncaught (in promise)_ errors while still keeping the possibility to check if the Navigation failed or not.
109+
110+
### Navigation Failures
111+
112+
There are a few different navigation failures, to be able to react differently in your code
113+
114+
Navigation failures can be differentiated through a `type` property. All possible values are hold by an `enum`, `NavigationFailureType`:
115+
116+
- `cancelled`: `next(false)` inside of a navigation guard or a newer navigation took place while another one was ongoing.
117+
- `duplicated`: Navigating to the same location as the current one will cancel the navigation and not invoke any Navigation guard.
118+
119+
On top of the `type` property, navigation failures also expose `from` and `to` properties, exactly like `router.afterEach`
120+
121+
### Redirections
122+
123+
Redirecting inside of a navigation guard with `next('/url')` is not a navigation failure by itself, as the navigation still takes place and ends up somewhere. To detect a navigation, specially during SSR, there is a `redirectedFrom` property accessible on the `currentRoute`.
124+
125+
E.g.: imagine a navigation guard that redirects to `/login` when the user isn't authenticated:
126+
127+
```js
128+
router.beforeEach((to, from, next) => {
129+
// redirect to the login page if the target location requires authentication
130+
if (to.meta.requiresAuth && !isAuthenticated) next('/login')
131+
else next()
132+
})
133+
```
134+
135+
When navigating to a location that requires authentication, we can retrieve the original location the user was trying to access via `redirectedFrom`:
136+
137+
```js
138+
// user is not authenticated
139+
await router.push('/profile/dashboard')
140+
141+
// `redirectedFrom` is a RouteLocationNormalized, like `currentRoute` but we are omitting
142+
// most properties to make the example readable
143+
router.currentRoute // { path: '/login', redirectedFrom: { path: '/profile/dashboard' } }
144+
```
145+
146+
## Changes to `router.afterEach`
147+
148+
Since `router.afterEach` also triggers when a navigation fails, we need a way to know if the navigation succeeded or failed. To do that, we introduce an extra parameter that contains the same _failure_ we could find in a resolved navigation:
149+
150+
```js
151+
import { NavigationFailureType } from 'vue-router'
152+
153+
router.afterEach((to, from, failure) => {
154+
if (failure) {
155+
if (failure.type === NavigationFailureType.canceled) {
156+
// ...
157+
}
158+
}
159+
})
160+
```
161+
162+
# Drawbacks
163+
164+
- Breaking change although migration is relatively simple and in many cases will allow the developer to remove existing code
165+
166+
# Alternatives
167+
168+
- Differentiating `next(false)` from navigations that get overridden by more recent navigations by defining another Navigation Failure
169+
170+
# Adoption strategy
171+
172+
- Expose `NavigationFailureType` in vue-router@3 so that Navigation Failures can be told apart from regular Errors. We could also expose a function `isNavigationFailure` to tell them apart.
173+
- `afterEach` and `onError` are relatively simple to migrate, most of the time they are not used many times either.
174+
- `router.push` doesn't reject when navigation fails anymore. Any code relying on catching an error should await the promise result instead.

0 commit comments

Comments
 (0)