Repository: vuejs/vue
Pull request: fix(core): cleanup timeouts for async components (fix #9648)

<!--
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) -->

**What kind of change does this PR introduce?** (check at least one)

- [x] Bugfix
- [ ] 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
- [ ] 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:**
component-async.spec.js now captures information about all created, triggered and cleared timeouts and verifies after the test that they have been cleared. 
Should fix #9648

Changed files:
filename: src/core/vdom/helpers/resolve-async-component.js
patch: @@ -65,6 +65,8 @@ export function resolveAsyncComponent (
   if (owner && !isDef(factory.owners)) {
     const owners = factory.owners = [owner]
     let sync = true
+    let timerLoading = null
+    let timerTimeout = null
 
     ;(owner: any).$on('hook:destroyed', () => remove(owners, owner))
 
@@ -75,6 +77,14 @@ export function resolveAsyncComponent (
 
       if (renderCompleted) {
         owners.length = 0
+        if (timerLoading !== null) {
+          clearTimeout(timerLoading)
+          timerLoading = null
+        }
+        if (timerTimeout !== null) {
+          clearTimeout(timerTimeout)
+          timerTimeout = null
+        }
       }
     }
 
@@ -121,7 +131,8 @@ export function resolveAsyncComponent (
           if (res.delay === 0) {
             factory.loading = true
           } else {
-            setTimeout(() => {
+            timerLoading = setTimeout(() => {
+              timerLoading = null
               if (isUndef(factory.resolved) && isUndef(factory.error)) {
                 factory.loading = true
                 forceRender(false)
@@ -131,7 +142,8 @@ export function resolveAsyncComponent (
         }
 
         if (isDef(res.timeout)) {
-          setTimeout(() => {
+          timerTimeout = setTimeout(() => {
+            timerTimeout = null
             if (isUndef(factory.resolved)) {
               reject(
                 process.env.NODE_ENV !== 'production'

filename: test/unit/features/component/component-async.spec.js
patch: @@ -2,6 +2,42 @@ import Vue from 'vue'
 import { Promise } from 'es6-promise'
 
 describe('Component async', () => {
+
+  const oldSetTimeout = window.setTimeout;
+  const oldClearTimeout = window.clearTimeout;
+
+  // will contain pending timeouts set during the test iteration
+  // will contain the id of the timeout as the key, and the the millisecond timeout as the value
+  // this helps to identify the timeout that is still pending
+  let timeoutsPending = {};
+
+  beforeEach(function () {
+    // reset the timeouts for this iteration
+    timeoutsPending = {};
+
+    window.setTimeout = function(func, delay) {
+      let id = oldSetTimeout(function() {
+        delete timeoutsPending[id];
+        func();
+      }, delay);
+      timeoutsPending[id] = delay;
+      return id
+    };
+
+    window.clearTimeout = function(id) {
+      oldClearTimeout(id);
+      delete timeoutsPending[id];
+    };
+  })
+
+  afterEach(function () {
+    window.setTimeout = oldSetTimeout;
+    window.clearTimeout = oldClearTimeout;
+    // after the test is complete no timeouts that have been set up during the test should still be active
+    // compare stringified JSON for better error message containing ID and millisecond timeout
+    expect(JSON.stringify(timeoutsPending)).toEqual(JSON.stringify({}))
+  })
+
   it('normal', done => {
     const vm = new Vue({
       template: '<div><test></test></div>',
@@ -343,6 +379,34 @@ describe('Component async', () => {
       }, 50)
     })
 
+    it('should not have running timeout/loading if resolved', done => {
+      const vm = new Vue({
+        template: `<div><test/></div>`,
+        components: {
+          test: () => ({
+            component: new Promise((resolve, reject) => {
+              setTimeout(() => {
+                resolve({ template: '<div>hi</div>' })
+                Promise.resolve().then(() => {
+                  Vue.nextTick(next)
+                })
+              }, 10)
+            }),
+            loading: { template: `<div>loading</div>` },
+            delay: 30,
+            error: { template: `<div>error</div>` },
+            timeout: 40
+          })
+        }
+      }).$mount()
+
+      function next () {
+        expect(vm.$el.textContent).toBe('hi')
+        // the afterEach() will ensure that the timeouts for delay and timeout have been cleared
+        done()
+      }
+    })
+
     // #7107
     it(`should work when resolving sync in sibling component's mounted hook`, done => {
       let resolveTwo