Repository: vuejs/vue
Pull request: fix(errorHandler): async error handling for watchers

Added async error handling for watchers and immediate watchers

**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:**
Fixes #9625 
Fixes #10009

Changed files:
filename: src/core/instance/state.js
patch: @@ -25,7 +25,8 @@ import {
   validateProp,
   isPlainObject,
   isServerRendering,
-  isReservedAttribute
+  isReservedAttribute,
+  invokeWithErrorHandling
 } from '../util/index'
 
 const sharedPropertyDefinition = {
@@ -357,12 +358,9 @@ export function stateMixin (Vue: Class<Component>) {
     options.user = true
     const watcher = new Watcher(vm, expOrFn, cb, options)
     if (options.immediate) {
+      const info = `callback for immediate watcher "${watcher.expression}"`
       pushTarget()
-      try {
-        cb.call(vm, watcher.value)
-      } catch (error) {
-        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
-      }
+      invokeWithErrorHandling(cb, vm, [watcher.value], vm, info)
       popTarget()
     }
     return function unwatchFn () {

filename: src/core/observer/watcher.js
patch: @@ -7,6 +7,7 @@ import {
   parsePath,
   _Set as Set,
   handleError,
+  invokeWithErrorHandling,
   noop
 } from '../util/index'
 
@@ -191,11 +192,8 @@ export default class Watcher {
         const oldValue = this.value
         this.value = value
         if (this.user) {
-          try {
-            this.cb.call(this.vm, value, oldValue)
-          } catch (e) {
-            handleError(e, this.vm, `callback for watcher "${this.expression}"`)
-          }
+          const info = `callback for watcher "${this.expression}"`
+          invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
         } else {
           this.cb.call(this.vm, value, oldValue)
         }

filename: test/unit/features/error-handling.spec.js
patch: @@ -127,25 +127,25 @@ describe('Error handling', () => {
     }).then(done)
   })
 
-  it('should recover from errors in user watcher callback', done => {
-    const vm = createTestInstance(components.userWatcherCallback)
-    vm.n++
-    waitForUpdate(() => {
-      expect(`Error in callback for watcher "n"`).toHaveBeenWarned()
-      expect(`Error: userWatcherCallback`).toHaveBeenWarned()
-    }).thenWaitFor(next => {
-      assertBothInstancesActive(vm).end(next)
-    }).then(done)
-  })
+  ;[
+    ['userWatcherCallback', 'watcher'],
+    ['userImmediateWatcherCallback', 'immediate watcher']
+  ].forEach(([type, description]) => {
+    it(`should recover from errors in user ${description} callback`, done => {
+      const vm = createTestInstance(components[type])
+      assertBothInstancesActive(vm).then(() => {
+        expect(`Error in callback for ${description} "n"`).toHaveBeenWarned()
+        expect(`Error: ${type} error`).toHaveBeenWarned()
+      }).then(done)
+    })
 
-  it('should recover from errors in user immediate watcher callback', done => {
-    const vm = createTestInstance(components.userImmediateWatcherCallback)
-    waitForUpdate(() => {
-      expect(`Error in callback for immediate watcher "n"`).toHaveBeenWarned()
-      expect(`Error: userImmediateWatcherCallback error`).toHaveBeenWarned()
-    }).thenWaitFor(next => {
-      assertBothInstancesActive(vm).end(next)
-    }).then(done)
+    it(`should recover from promise errors in user ${description} callback`, done => {
+      const vm = createTestInstance(components[`${type}Async`])
+      assertBothInstancesActive(vm).then(() => {
+        expect(`Error in callback for ${description} "n" (Promise/async)`).toHaveBeenWarned()
+        expect(`Error: ${type} error`).toHaveBeenWarned()
+      }).then(done)
+    })
   })
 
   it('config.errorHandler should capture render errors', done => {
@@ -359,6 +359,33 @@ function createErrorTestComponents () {
     }
   }
 
+  components.userWatcherCallbackAsync = {
+    props: ['n'],
+    watch: {
+      n () {
+        return Promise.reject(new Error('userWatcherCallback error'))
+      }
+    },
+    render (h) {
+      return h('div', this.n)
+    }
+  }
+
+  components.userImmediateWatcherCallbackAsync = {
+    props: ['n'],
+    watch: {
+      n: {
+        immediate: true,
+        handler () {
+          return Promise.reject(new Error('userImmediateWatcherCallback error'))
+        }
+      }
+    },
+    render (h) {
+      return h('div', this.n)
+    }
+  }
+
   // event errors
   components.event = {
     beforeCreate () {

filename: test/unit/features/options/errorCaptured.spec.js
patch: @@ -247,4 +247,152 @@ describe('Options errorCaptured', () => {
       expect(store.errors[0]).toEqual(new Error('render error'))
     }).then(done)
   })
+
+  it('should capture error from watcher', done => {
+    const spy = jasmine.createSpy()
+
+    let child
+    let err
+    const Child = {
+      data () {
+        return {
+          foo: null
+        }
+      },
+      watch: {
+        foo () {
+          err = new Error('userWatcherCallback error')
+          throw err
+        }
+      },
+      created () {
+        child = this
+      },
+      render () {}
+    }
+
+    new Vue({
+      errorCaptured: spy,
+      render: h => h(Child)
+    }).$mount()
+
+    child.foo = 'bar'
+
+    waitForUpdate(() => {
+      expect(spy).toHaveBeenCalledWith(err, child, 'callback for watcher "foo"')
+      expect(globalSpy).toHaveBeenCalledWith(err, child, 'callback for watcher "foo"')
+    }).then(done)
+  })
+
+  it('should capture promise error from watcher', done => {
+    const spy = jasmine.createSpy()
+
+    let child
+    let err
+    const Child = {
+      data () {
+        return {
+          foo: null
+        }
+      },
+      watch: {
+        foo () {
+          err = new Error('userWatcherCallback error')
+          return Promise.reject(err)
+        }
+      },
+      created () {
+        child = this
+      },
+      render () {}
+    }
+
+    new Vue({
+      errorCaptured: spy,
+      render: h => h(Child)
+    }).$mount()
+
+    child.foo = 'bar'
+
+    child.$nextTick(() => {
+      waitForUpdate(() => {
+        expect(spy).toHaveBeenCalledWith(err, child, 'callback for watcher "foo" (Promise/async)')
+        expect(globalSpy).toHaveBeenCalledWith(err, child, 'callback for watcher "foo" (Promise/async)')
+      }).then(done)
+    })
+  })
+
+  it('should capture error from immediate watcher', done => {
+    const spy = jasmine.createSpy()
+
+    let child
+    let err
+    const Child = {
+      data () {
+        return {
+          foo: 'foo'
+        }
+      },
+      watch: {
+        foo: {
+          immediate: true,
+          handler () {
+            err = new Error('userImmediateWatcherCallback error')
+            throw err
+          }
+        }
+      },
+      created () {
+        child = this
+      },
+      render () {}
+    }
+
+    new Vue({
+      errorCaptured: spy,
+      render: h => h(Child)
+    }).$mount()
+
+    waitForUpdate(() => {
+      expect(spy).toHaveBeenCalledWith(err, child, 'callback for immediate watcher "foo"')
+      expect(globalSpy).toHaveBeenCalledWith(err, child, 'callback for immediate watcher "foo"')
+    }).then(done)
+  })
+
+  it('should capture promise error from immediate watcher', done => {
+    const spy = jasmine.createSpy()
+
+    let child
+    let err
+    const Child = {
+      data () {
+        return {
+          foo: 'foo'
+        }
+      },
+      watch: {
+        foo: {
+          immediate: true,
+          handler () {
+            err = new Error('userImmediateWatcherCallback error')
+            return Promise.reject(err)
+          }
+        }
+      },
+      created () {
+        child = this
+      },
+      render () {}
+    }
+
+    new Vue({
+      errorCaptured: spy,
+      render: h => h(Child)
+    }).$mount()
+
+    waitForUpdate(() => {
+      expect(spy).toHaveBeenCalledWith(err, child, 'callback for immediate watcher "foo" (Promise/async)')
+      expect(globalSpy).toHaveBeenCalledWith(err, child, 'callback for immediate watcher "foo" (Promise/async)')
+    }).then(done)
+  })
 })