Repository: vuejs/vue
Pull request: fix(keep-alive): cache what is really needed not the whole VNode data

<!--
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)

- [ ] Bugfix
- [ ] Feature
- [ ] Code style update
- [ ] Refactor
- [ ] Build-related changes
- [ ] Other, please describe:

**Does this PR introduce a breaking change?** (check one)

- [ ] Yes
- [ ] No

If yes, please describe the impact and migration path for existing applications:

**The PR fulfills these requirements:**

- [ ] 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)
- [ ] All tests are passing: https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#development-setup
- [ ] 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 #9842
Fixes https://github.com/vuejs/vue-router/issues/2549
Fixes #9972

Changed files:
filename: src/core/components/keep-alive.js
patch: @@ -3,7 +3,13 @@
 import { isRegExp, remove } from 'shared/util'
 import { getFirstComponentChild } from 'core/vdom/helpers/index'
 
-type VNodeCache = { [key: string]: ?VNode };
+type CacheEntry = {
+  name: ?string;
+  tag: ?string;
+  componentInstance: Component;
+};
+
+type CacheEntryMap = { [key: string]: ?CacheEntry };
 
 function getComponentName (opts: ?VNodeComponentOptions): ?string {
   return opts && (opts.Ctor.options.name || opts.tag)
@@ -24,9 +30,9 @@ function matches (pattern: string | RegExp | Array<string>, name: string): boole
 function pruneCache (keepAliveInstance: any, filter: Function) {
   const { cache, keys, _vnode } = keepAliveInstance
   for (const key in cache) {
-    const cachedNode: ?VNode = cache[key]
-    if (cachedNode) {
-      const name: ?string = getComponentName(cachedNode.componentOptions)
+    const entry: ?CacheEntry = cache[key]
+    if (entry) {
+      const name: ?string = entry.name
       if (name && !filter(name)) {
         pruneCacheEntry(cache, key, keys, _vnode)
       }
@@ -35,14 +41,14 @@ function pruneCache (keepAliveInstance: any, filter: Function) {
 }
 
 function pruneCacheEntry (
-  cache: VNodeCache,
+  cache: CacheEntryMap,
   key: string,
   keys: Array<string>,
   current?: VNode
 ) {
-  const cached = cache[key]
-  if (cached && (!current || cached.tag !== current.tag)) {
-    cached.componentInstance.$destroy()
+  const entry: ?CacheEntry = cache[key]
+  if (entry && (!current || entry.tag !== current.tag)) {
+    entry.componentInstance.$destroy()
   }
   cache[key] = null
   remove(keys, key)
@@ -60,6 +66,26 @@ export default {
     max: [String, Number]
   },
 
+  methods: {
+    cacheVNode() {
+      const { cache, keys, vnodeToCache, keyToCache } = this
+      if (vnodeToCache) {
+        const { tag, componentInstance, componentOptions } = vnodeToCache
+        cache[keyToCache] = {
+          name: getComponentName(componentOptions),
+          tag,
+          componentInstance,
+        }
+        keys.push(keyToCache)
+        // prune oldest entry
+        if (this.max && keys.length > parseInt(this.max)) {
+          pruneCacheEntry(cache, keys[0], keys, this._vnode)
+        }
+        this.vnodeToCache = null
+      }
+    }
+  },
+
   created () {
     this.cache = Object.create(null)
     this.keys = []
@@ -72,6 +98,7 @@ export default {
   },
 
   mounted () {
+    this.cacheVNode()
     this.$watch('include', val => {
       pruneCache(this, name => matches(val, name))
     })
@@ -80,6 +107,10 @@ export default {
     })
   },
 
+  updated () {
+    this.cacheVNode()
+  },
+
   render () {
     const slot = this.$slots.default
     const vnode: VNode = getFirstComponentChild(slot)
@@ -109,12 +140,9 @@ export default {
         remove(keys, key)
         keys.push(key)
       } else {
-        cache[key] = vnode
-        keys.push(key)
-        // prune oldest entry
-        if (this.max && keys.length > parseInt(this.max)) {
-          pruneCacheEntry(cache, keys[0], keys, this._vnode)
-        }
+        // delay setting the cache until update
+        this.vnodeToCache = vnode
+        this.keyToCache = key
       }
 
       vnode.data.keepAlive = true

filename: test/unit/features/component/component-keep-alive.spec.js
patch: @@ -572,6 +572,73 @@ describe('Component keep-alive', () => {
     }).then(done)
   })
 
+  it('max=1', done => {
+    const spyA = jasmine.createSpy()
+    const spyB = jasmine.createSpy()
+    const spyC = jasmine.createSpy()
+    const spyAD = jasmine.createSpy()
+    const spyBD = jasmine.createSpy()
+    const spyCD = jasmine.createSpy()
+
+    function assertCount (calls) {
+      expect([
+        spyA.calls.count(),
+        spyAD.calls.count(),
+        spyB.calls.count(),
+        spyBD.calls.count(),
+        spyC.calls.count(),
+        spyCD.calls.count()
+      ]).toEqual(calls)
+    }
+
+    const vm = new Vue({
+      template: `
+        <keep-alive max="1">
+          <component :is="n"></component>
+        </keep-alive>
+      `,
+      data: {
+        n: 'aa'
+      },
+      components: {
+        aa: {
+          template: '<div>a</div>',
+          created: spyA,
+          destroyed: spyAD
+        },
+        bb: {
+          template: '<div>bbb</div>',
+          created: spyB,
+          destroyed: spyBD
+        },
+        cc: {
+          template: '<div>ccc</div>',
+          created: spyC,
+          destroyed: spyCD
+        }
+      }
+    }).$mount()
+
+    assertCount([1, 0, 0, 0, 0, 0])
+    vm.n = 'bb'
+    waitForUpdate(() => {
+      // should prune A because max cache reached
+      assertCount([1, 1, 1, 0, 0, 0])
+      vm.n = 'cc'
+    }).then(() => {
+      // should prune B because max cache reached
+      assertCount([1, 1, 1, 1, 1, 0])
+      vm.n = 'bb'
+    }).then(() => {
+      // B is recreated
+      assertCount([1, 1, 2, 1, 1, 1])
+      vm.n = 'aa'
+    }).then(() => {
+      // B is destroyed and A recreated
+      assertCount([2, 1, 2, 2, 1, 1])
+    }).then(done)
+  })
+
   it('should warn unknown component inside', () => {
     new Vue({
       template: `<keep-alive><foo/></keep-alive>`