|
| 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