Repository: vuejs/vue
Pull request: feat(errors): sync/async error handling for lifecycle hooks and v-on handlers (#7653, #6953)

<!--
Please make sure to read the Pull Request Guidelines:
https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#pull-request-guidelines
-->

<!-- PULL REQUEST TEMPLATE -->
<!-- (Update "[ ]" to "[x]" to check a box) -->

- Added async errors handling for lifecycle hooks and event handlers (issue link: #7653). 

Update:
- Added normal and async errors handling in v-on handlers from @coldino (issue link: #6953).

**What kind of change does this PR introduce?** (check at least one)

- [ ] Bugfix
- [x] Feature
- [ ] Code style update
- [ ] Refactor
- [ ] Build-related changes
- [ ] Other, please describe:

**Does this PR introduce a breaking change?** (check one)

- [ ] Yes
- [x] No

If yes, please describe the impact and migration path for existing applications:

**The PR fulfills these requirements:**

- [x] It's submitted to the `dev` branch for v2.x (or to a previous version branch), _not_ the `master` branch
- [x] When resolving a specific issue, it's referenced in the PR's title (e.g. `fix #xxx[,#xxx]`, where "xxx" is the issue number)
- [x] All tests are passing: https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#development-setup
- [x] New/updated tests are included

If adding a **new feature**, the PR's description includes:
- [ ] A convincing reason for adding this feature (to avoid wasting your time, it's best to open a suggestion issue first and wait for approval before working on it)

**Other information:**


Changed files:
filename: src/core/instance/events.js
patch: @@ -5,7 +5,8 @@ import {
   toArray,
   hyphenate,
   handleError,
-  formatComponentName
+  formatComponentName,
+  handlePromiseError
 } from '../util/index'
 import { updateListeners } from '../vdom/helpers/index'
 
@@ -135,7 +136,8 @@ export function eventsMixin (Vue: Class<Component>) {
       const args = toArray(arguments, 1)
       for (let i = 0, l = cbs.length; i < l; i++) {
         try {
-          cbs[i].apply(vm, args)
+          const cbResult = cbs[i].apply(vm, args)
+          handlePromiseError(cbResult, vm, `event handler for "${event}"`)
         } catch (e) {
           handleError(e, vm, `event handler for "${event}"`)
         }

filename: src/core/instance/lifecycle.js
patch: @@ -15,7 +15,8 @@ import {
   remove,
   handleError,
   emptyObject,
-  validateProp
+  validateProp,
+  handlePromiseError
 } from '../util/index'
 
 export let activeInstance: any = null
@@ -326,7 +327,8 @@ export function callHook (vm: Component, hook: string) {
   if (handlers) {
     for (let i = 0, j = handlers.length; i < j; i++) {
       try {
-        handlers[i].call(vm)
+        const fnResult = handlers[i].call(vm)
+        handlePromiseError(fnResult, vm, `${hook} hook`)
       } catch (e) {
         handleError(e, vm, `${hook} hook`)
       }

filename: src/core/util/error.js
patch: @@ -24,6 +24,13 @@ export function handleError (err: Error, vm: any, info: string) {
   globalHandleError(err, vm, info)
 }
 
+export function handlePromiseError (value: any, vm: any, info: string) {
+  // if value is promise, handle it (a promise must have a then function)
+  if (value && typeof value.then === 'function' && typeof value.catch === 'function') {
+    value.catch(e => handleError(e, vm, info))
+  }
+}
+
 function globalHandleError (err, vm, info) {
   if (config.errorHandler) {
     try {

filename: src/core/vdom/helpers/update-listeners.js
patch: @@ -1,7 +1,6 @@
 /* @flow */
 
-import { warn } from 'core/util/index'
-
+import { warn, handleError, handlePromiseError } from 'core/util/index'
 import {
   cached,
   isUndef,
@@ -31,17 +30,29 @@ const normalizeEvent = cached((name: string): {
   }
 })
 
-export function createFnInvoker (fns: Function | Array<Function>): Function {
+export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
   function invoker () {
     const fns = invoker.fns
     if (Array.isArray(fns)) {
       const cloned = fns.slice()
       for (let i = 0; i < cloned.length; i++) {
-        cloned[i].apply(null, arguments)
+        try {
+          const result = cloned[i].apply(null, arguments)
+          handlePromiseError(result, vm, 'v-on async')
+        } catch (e) {
+          handleError(e, vm, 'v-on')
+        }
       }
     } else {
       // return handler return value for single handlers
-      return fns.apply(null, arguments)
+      let result
+      try {
+        result = fns.apply(null, arguments)
+        handlePromiseError(result, vm, 'v-on async')
+      } catch (e) {
+        handleError(e, vm, 'v-on')
+      }
+      return result
     }
   }
   invoker.fns = fns
@@ -73,7 +84,7 @@ export function updateListeners (
       )
     } else if (isUndef(old)) {
       if (isUndef(cur.fns)) {
-        cur = on[name] = createFnInvoker(cur)
+        cur = on[name] = createFnInvoker(cur, vm)
       }
       if (isTrue(event.once)) {
         cur = on[name] = createOnceHandler(event.name, cur, event.capture)

filename: test/unit/features/error-handling.spec.js
patch: @@ -22,6 +22,23 @@ describe('Error handling', () => {
     })
   })
 
+  // hooks that can return rejected promise
+  ;[
+    ['beforeCreate', 'beforeCreate hook'],
+    ['created', 'created hook'],
+    ['beforeMount', 'beforeMount hook'],
+    ['mounted', 'mounted hook'],
+    ['event', 'event handler for "e"']
+  ].forEach(([type, description]) => {
+    it(`should recover from promise errors in ${type}`, done => {
+      createTestInstance(components[`${type}Async`])
+      waitForUpdate(() => {
+        expect(`Error in ${description}`).toHaveBeenWarned()
+        expect(`Error: ${type}`).toHaveBeenWarned()
+      }).then(done)
+    })
+  })
+
   // error in mounted hook should affect neither child nor parent
   it('should recover from errors in mounted hook', done => {
     const vm = createTestInstance(components.mounted)
@@ -45,6 +62,20 @@ describe('Error handling', () => {
     })
   })
 
+  // hooks that can return rejected promise
+  ;[
+    ['beforeUpdate', 'beforeUpdate hook'],
+    ['updated', 'updated hook']
+  ].forEach(([type, description]) => {
+    it(`should recover from promise errors in ${type} hook`, done => {
+      const vm = createTestInstance(components[`${type}Async`])
+      assertBothInstancesActive(vm).then(() => {
+        expect(`Error in ${description}`).toHaveBeenWarned()
+        expect(`Error: ${type}`).toHaveBeenWarned()
+      }).then(done)
+    })
+  })
+
   ;[
     ['beforeDestroy', 'beforeDestroy hook'],
     ['destroyed', 'destroyed hook'],
@@ -62,6 +93,21 @@ describe('Error handling', () => {
     })
   })
 
+  ;[
+    ['beforeDestroy', 'beforeDestroy hook'],
+    ['destroyed', 'destroyed hook']
+  ].forEach(([type, description]) => {
+    it(`should recover from promise errors in ${type} hook`, done => {
+      const vm = createTestInstance(components[`${type}Async`])
+      vm.ok = false
+      setTimeout(() => {
+        expect(`Error in ${description}`).toHaveBeenWarned()
+        expect(`Error: ${type}`).toHaveBeenWarned()
+        assertRootInstanceActive(vm).then(done)
+      })
+    })
+  })
+
   it('should recover from errors in user watcher getter', done => {
     const vm = createTestInstance(components.userWatcherGetter)
     vm.n++
@@ -152,6 +198,40 @@ describe('Error handling', () => {
     expect(vm.$el.textContent).toContain('error in render')
     Vue.config.errorHandler = null
   })
+
+  // event handlers that can throw errors or return rejected promise
+  ;[
+    ['single handler', '<div v-on:click="bork"></div>'],
+    ['multiple handlers', '<div v-on="{ click: [bork, function test() {}] }"></div>']
+  ].forEach(([type, template]) => {
+    it(`should recover from v-on errors for ${type} registered`, () => {
+      const vm = new Vue({
+        template,
+        methods: { bork () { throw new Error('v-on') } }
+      }).$mount()
+      document.body.appendChild(vm.$el)
+      triggerEvent(vm.$el, 'click')
+      expect('Error in v-on').toHaveBeenWarned()
+      expect('Error: v-on').toHaveBeenWarned()
+      document.body.removeChild(vm.$el)
+    })
+
+    it(`should recover from v-on async errors for ${type} registered`, (done) => {
+      const vm = new Vue({
+        template,
+        methods: { bork () {
+          return new Promise((resolve, reject) => reject(new Error('v-on async')))
+        } }
+      }).$mount()
+      document.body.appendChild(vm.$el)
+      triggerEvent(vm.$el, 'click')
+      waitForUpdate(() => {
+        expect('Error in v-on async').toHaveBeenWarned()
+        expect('Error: v-on async').toHaveBeenWarned()
+        document.body.removeChild(vm.$el)
+      }).then(done)
+    })
+  })
 })
 
 function createErrorTestComponents () {
@@ -188,6 +268,16 @@ function createErrorTestComponents () {
       throw new Error(before)
     }
 
+    const beforeCompAsync = components[`${before}Async`] = {
+      props: ['n'],
+      render (h) {
+        return h('div', this.n)
+      }
+    }
+    beforeCompAsync[before] = function () {
+      return new Promise((resolve, reject) => reject(new Error(before)))
+    }
+
     // after
     const after = hook.replace(/e?$/, 'ed')
     const afterComp = components[after] = {
@@ -199,6 +289,16 @@ function createErrorTestComponents () {
     afterComp[after] = function () {
       throw new Error(after)
     }
+
+    const afterCompAsync = components[`${after}Async`] = {
+      props: ['n'],
+      render (h) {
+        return h('div', this.n)
+      }
+    }
+    afterCompAsync[after] = function () {
+      return new Promise((resolve, reject) => reject(new Error(after)))
+    }
   })
 
   // directive hooks errors
@@ -272,6 +372,18 @@ function createErrorTestComponents () {
     }
   }
 
+  components.eventAsync = {
+    beforeCreate () {
+      this.$on('e', () => new Promise((resolve, reject) => reject(new Error('event'))))
+    },
+    mounted () {
+      this.$emit('e')
+    },
+    render (h) {
+      return h('div')
+    }
+  }
+
   return components
 }