From d91a38ab6a0cc41e32b91ed1e12c0ed42877a63b Mon Sep 17 00:00:00 2001
From: Evan You <yyx990803@gmail.com>
Date: Fri, 25 Jan 2019 14:55:27 -0500
Subject: [PATCH] perf: improve scoped slots change detection accuracy

---
 flow/compiler.js                              |   1 +
 src/compiler/codegen/index.js                 |   3 +-
 src/compiler/parser/index.js                  |  18 +-
 src/core/instance/lifecycle.js                |  20 +-
 .../instance/render-helpers/resolve-slots.js  |   7 +-
 .../vdom/helpers/normalize-scoped-slots.js    |   3 +-
 .../component/component-scoped-slot.spec.js   | 341 ++++++++++--------
 test/unit/modules/compiler/codegen.spec.js    |   7 +
 8 files changed, 233 insertions(+), 167 deletions(-)

diff --git a/flow/compiler.js b/flow/compiler.js
index de1e2df9a4a..a1af7a6cec8 100644
--- a/flow/compiler.js
+++ b/flow/compiler.js
@@ -119,6 +119,7 @@ declare type ASTElement = {
   transitionMode?: string | null;
   slotName?: ?string;
   slotTarget?: ?string;
+  slotTargetDynamic?: boolean;
   slotScope?: ?string;
   scopedSlots?: { [name: string]: ASTElement };
 
diff --git a/src/compiler/codegen/index.js b/src/compiler/codegen/index.js
index bdd76be0033..9f23b37e102 100644
--- a/src/compiler/codegen/index.js
+++ b/src/compiler/codegen/index.js
@@ -354,11 +354,12 @@ function genScopedSlots (
   slots: { [key: string]: ASTElement },
   state: CodegenState
 ): string {
+  const hasDynamicKeys = Object.keys(slots).some(key => slots[key].slotTargetDynamic)
   return `scopedSlots:_u([${
     Object.keys(slots).map(key => {
       return genScopedSlot(key, slots[key], state)
     }).join(',')
-  }])`
+  }]${hasDynamicKeys ? `,true` : ``})`
 }
 
 function genScopedSlot (
diff --git a/src/compiler/parser/index.js b/src/compiler/parser/index.js
index 1f59bb93519..601dd8f26d9 100644
--- a/src/compiler/parser/index.js
+++ b/src/compiler/parser/index.js
@@ -586,6 +586,7 @@ function processSlotContent (el) {
   const slotTarget = getBindingAttr(el, 'slot')
   if (slotTarget) {
     el.slotTarget = slotTarget === '""' ? '"default"' : slotTarget
+    el.slotTargetDynamic = !!(el.attrsMap[':slot'] || el.attrsMap['v-bind:slot'])
     // preserve slot as an attribute for native shadow DOM compat
     // only for non-scoped slots.
     if (el.tag !== 'template' && !el.slotScope) {
@@ -607,8 +608,10 @@ function processSlotContent (el) {
             )
           }
         }
-        el.slotTarget = getSlotName(slotBinding)
-        el.slotScope = slotBinding.value
+        const { name, dynamic } = getSlotName(slotBinding)
+        el.slotTarget = name
+        el.slotTargetDynamic = dynamic
+        el.slotScope = slotBinding.value || `_` // force it into a scoped slot for perf
       }
     } else {
       // v-slot on component, denotes default slot
@@ -637,10 +640,11 @@ function processSlotContent (el) {
         }
         // add the component's children to its default slot
         const slots = el.scopedSlots || (el.scopedSlots = {})
-        const target = getSlotName(slotBinding)
-        const slotContainer = slots[target] = createASTElement('template', [], el)
+        const { name, dynamic } = getSlotName(slotBinding)
+        const slotContainer = slots[name] = createASTElement('template', [], el)
+        slotContainer.slotTargetDynamic = dynamic
         slotContainer.children = el.children
-        slotContainer.slotScope = slotBinding.value
+        slotContainer.slotScope = slotBinding.value || `_`
         // remove children as they are returned from scopedSlots now
         el.children = []
         // mark el non-plain so data gets generated
@@ -664,9 +668,9 @@ function getSlotName (binding) {
   }
   return dynamicKeyRE.test(name)
     // dynamic [name]
-    ? name.slice(1, -1)
+    ? { name: name.slice(1, -1), dynamic: true }
     // static name
-    : `"${name}"`
+    : { name: `"${name}"`, dynamic: false }
 }
 
 // handle <slot/> outlets
diff --git a/src/core/instance/lifecycle.js b/src/core/instance/lifecycle.js
index 3879c7b5d7f..debe94bc068 100644
--- a/src/core/instance/lifecycle.js
+++ b/src/core/instance/lifecycle.js
@@ -224,12 +224,22 @@ export function updateChildComponent (
   }
 
   // determine whether component has slot children
-  // we need to do this before overwriting $options._renderChildren
-  const hasChildren = !!(
+  // we need to do this before overwriting $options._renderChildren.
+
+  // check if there are dynamic scopedSlots (hand-written or compiled but with
+  // dynamic slot names). Static scoped slots compiled from template has the
+  // "$stable" marker.
+  const hasDynamicScopedSlot = !!(
+    (parentVnode.data.scopedSlots && !parentVnode.data.scopedSlots.$stable) ||
+    (vm.$scopedSlots !== emptyObject && !vm.$scopedSlots.$stable)
+  )
+  // Any static slot children from the parent may have changed during parent's
+  // update. Dynamic scoped slots may also have changed. In such cases, a forced
+  // update is necessary to ensure correctness.
+  const needsForceUpdate = !!(
     renderChildren ||               // has new static slots
     vm.$options._renderChildren ||  // has old static slots
-    parentVnode.data.scopedSlots || // has new scoped slots
-    vm.$scopedSlots !== emptyObject // has old scoped slots
+    hasDynamicScopedSlot
   )
 
   vm.$options._parentVnode = parentVnode
@@ -268,7 +278,7 @@ export function updateChildComponent (
   updateComponentListeners(vm, listeners, oldListeners)
 
   // resolve slots + force update if has children
-  if (hasChildren) {
+  if (needsForceUpdate) {
     vm.$slots = resolveSlots(renderChildren, parentVnode.context)
     vm.$forceUpdate()
   }
diff --git a/src/core/instance/render-helpers/resolve-slots.js b/src/core/instance/render-helpers/resolve-slots.js
index dbec386a19c..53bad202172 100644
--- a/src/core/instance/render-helpers/resolve-slots.js
+++ b/src/core/instance/render-helpers/resolve-slots.js
@@ -51,13 +51,14 @@ function isWhitespace (node: VNode): boolean {
 
 export function resolveScopedSlots (
   fns: ScopedSlotsData, // see flow/vnode
+  hasDynamicKeys?: boolean,
   res?: Object
-): { [key: string]: Function } {
-  res = res || {}
+): { [key: string]: Function, $stable: boolean } {
+  res = res || { $stable: !hasDynamicKeys }
   for (let i = 0; i < fns.length; i++) {
     const slot = fns[i]
     if (Array.isArray(slot)) {
-      resolveScopedSlots(slot, res)
+      resolveScopedSlots(slot, hasDynamicKeys, res)
     } else {
       res[slot.key] = slot.fn
     }
diff --git a/src/core/vdom/helpers/normalize-scoped-slots.js b/src/core/vdom/helpers/normalize-scoped-slots.js
index 5e5f0091e79..aa78838e770 100644
--- a/src/core/vdom/helpers/normalize-scoped-slots.js
+++ b/src/core/vdom/helpers/normalize-scoped-slots.js
@@ -14,7 +14,7 @@ export function normalizeScopedSlots (
   } else {
     res = {}
     for (const key in slots) {
-      if (slots[key]) {
+      if (slots[key] && key[0] !== '$') {
         res[key] = normalizeScopedSlot(slots[key])
       }
     }
@@ -26,6 +26,7 @@ export function normalizeScopedSlots (
     }
   }
   res._normalized = true
+  res.$stable = slots && slots.$stable
   return res
 }
 
diff --git a/test/unit/features/component/component-scoped-slot.spec.js b/test/unit/features/component/component-scoped-slot.spec.js
index 1516aaddd2f..14d0ce4a876 100644
--- a/test/unit/features/component/component-scoped-slot.spec.js
+++ b/test/unit/features/component/component-scoped-slot.spec.js
@@ -633,131 +633,69 @@ describe('Component scoped slot', () => {
   })
 
   // 2.6 new slot syntax
-  if (process.env.NEW_SLOT_SYNTAX) {
-    describe('v-slot syntax', () => {
-      const Foo = {
-        render(h) {
-          return h('div', [
-            this.$scopedSlots.default && this.$scopedSlots.default('from foo default'),
-            this.$scopedSlots.one && this.$scopedSlots.one('from foo one'),
-            this.$scopedSlots.two && this.$scopedSlots.two('from foo two')
-          ])
-        }
+  describe('v-slot syntax', () => {
+    const Foo = {
+      render(h) {
+        return h('div', [
+          this.$scopedSlots.default && this.$scopedSlots.default('from foo default'),
+          this.$scopedSlots.one && this.$scopedSlots.one('from foo one'),
+          this.$scopedSlots.two && this.$scopedSlots.two('from foo two')
+        ])
       }
+    }
 
-      const Bar = {
-        render(h) {
-          return this.$scopedSlots.default && this.$scopedSlots.default('from bar')
-        }
+    const Bar = {
+      render(h) {
+        return this.$scopedSlots.default && this.$scopedSlots.default('from bar')
       }
+    }
 
-      const Baz = {
-        render(h) {
-          return this.$scopedSlots.default && this.$scopedSlots.default('from baz')
-        }
+    const Baz = {
+      render(h) {
+        return this.$scopedSlots.default && this.$scopedSlots.default('from baz')
       }
+    }
 
-      const toNamed = (syntax, name) => syntax[0] === '#'
-        ? `#${name}` // shorthand
-        : `${syntax}:${name}` // full syntax
-
-      function runSuite(syntax) {
-        it('default slot', () => {
-          const vm = new Vue({
-            template: `<foo ${syntax}="foo">{{ foo }}<div>{{ foo }}</div></foo>`,
-            components: { Foo }
-          }).$mount()
-          expect(vm.$el.innerHTML).toBe(`from foo default<div>from foo default</div>`)
-        })
-
-        it('nested default slots', () => {
-          const vm = new Vue({
-            template: `
-              <foo ${syntax}="foo">
-                <bar ${syntax}="bar">
-                  <baz ${syntax}="baz">
-                    {{ foo }} | {{ bar }} | {{ baz }}
-                  </baz>
-                </bar>
-              </foo>
-            `,
-            components: { Foo, Bar, Baz }
-          }).$mount()
-          expect(vm.$el.innerHTML.trim()).toBe(`from foo default | from bar | from baz`)
-        })
-
-        it('named slots', () => {
-          const vm = new Vue({
-            template: `
-              <foo>
-                <template ${toNamed(syntax, 'default')}="foo">
-                  {{ foo }}
-                </template>
-                <template ${toNamed(syntax, 'one')}="one">
-                  {{ one }}
-                </template>
-                <template ${toNamed(syntax, 'two')}="two">
-                  {{ two }}
-                </template>
-              </foo>
-            `,
-            components: { Foo }
-          }).$mount()
-          expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
-        })
-
-        it('nested + named + default slots', () => {
-          const vm = new Vue({
-            template: `
-              <foo>
-                <template ${toNamed(syntax, 'one')}="one">
-                  <bar ${syntax}="bar">
-                    {{ one }} {{ bar }}
-                  </bar>
-                </template>
-                <template ${toNamed(syntax, 'two')}="two">
-                  <baz ${syntax}="baz">
-                    {{ two }} {{ baz }}
-                  </baz>
-                </template>
-              </foo>
-            `,
-            components: { Foo, Bar, Baz }
-          }).$mount()
-          expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from bar from foo two from baz`)
-        })
+    const toNamed = (syntax, name) => syntax[0] === '#'
+      ? `#${name}` // shorthand
+      : `${syntax}:${name}` // full syntax
 
-        it('should warn v-slot usage on non-component elements', () => {
-          const vm = new Vue({
-            template: `<div ${syntax}="foo"/>`
-          }).$mount()
-          expect(`v-slot can only be used on components or <template>`).toHaveBeenWarned()
-        })
-
-        it('should warn mixed usage', () => {
-          const vm = new Vue({
-            template: `<foo><bar slot="one" slot-scope="bar" ${syntax}="bar"></bar></foo>`,
-            components: { Foo, Bar }
-          }).$mount()
-          expect(`Unexpected mixed usage of different slot syntaxes`).toHaveBeenWarned()
-        })
-      }
+    function runSuite(syntax) {
+      it('default slot', () => {
+        const vm = new Vue({
+          template: `<foo ${syntax}="foo">{{ foo }}<div>{{ foo }}</div></foo>`,
+          components: { Foo }
+        }).$mount()
+        expect(vm.$el.innerHTML).toBe(`from foo default<div>from foo default</div>`)
+      })
 
-      // run tests for both full syntax and shorthand
-      runSuite('v-slot')
-      runSuite('#default')
+      it('nested default slots', () => {
+        const vm = new Vue({
+          template: `
+            <foo ${syntax}="foo">
+              <bar ${syntax}="bar">
+                <baz ${syntax}="baz">
+                  {{ foo }} | {{ bar }} | {{ baz }}
+                </baz>
+              </bar>
+            </foo>
+          `,
+          components: { Foo, Bar, Baz }
+        }).$mount()
+        expect(vm.$el.innerHTML.trim()).toBe(`from foo default | from bar | from baz`)
+      })
 
-      it('shorthand named slots', () => {
+      it('named slots', () => {
         const vm = new Vue({
           template: `
             <foo>
-              <template #default="foo">
+              <template ${toNamed(syntax, 'default')}="foo">
                 {{ foo }}
               </template>
-              <template #one="one">
+              <template ${toNamed(syntax, 'one')}="one">
                 {{ one }}
               </template>
-              <template #two="two">
+              <template ${toNamed(syntax, 'two')}="two">
                 {{ two }}
               </template>
             </foo>
@@ -767,62 +705,165 @@ describe('Component scoped slot', () => {
         expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
       })
 
-      it('should warn mixed root-default and named slots', () => {
+      it('nested + named + default slots', () => {
         const vm = new Vue({
           template: `
-            <foo #default="foo">
-              {{ foo }}
-              <template #one="one">
-                {{ one }}
+            <foo>
+              <template ${toNamed(syntax, 'one')}="one">
+                <bar ${syntax}="bar">
+                  {{ one }} {{ bar }}
+                </bar>
+              </template>
+              <template ${toNamed(syntax, 'two')}="two">
+                <baz ${syntax}="baz">
+                  {{ two }} {{ baz }}
+                </baz>
               </template>
             </foo>
           `,
-          components: { Foo }
+          components: { Foo, Bar, Baz }
         }).$mount()
-        expect(`default slot should also use <template>`).toHaveBeenWarned()
+        expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from bar from foo two from baz`)
       })
 
-      it('shorthand without scope variable', () => {
+      it('should warn v-slot usage on non-component elements', () => {
         const vm = new Vue({
-          template: `
-            <foo>
-              <template #one>one</template>
-              <template #two>two</template>
-            </foo>
-          `,
-          components: { Foo }
+          template: `<div ${syntax}="foo"/>`
         }).$mount()
-        expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`onetwo`)
+        expect(`v-slot can only be used on components or <template>`).toHaveBeenWarned()
       })
 
-      it('shorthand named slots on root', () => {
+      it('should warn mixed usage', () => {
         const vm = new Vue({
-          template: `
-            <foo #one="one">
-              {{ one }}
-            </foo>
-          `,
-          components: { Foo }
+          template: `<foo><bar slot="one" slot-scope="bar" ${syntax}="bar"></bar></foo>`,
+          components: { Foo, Bar }
         }).$mount()
-        expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one`)
+        expect(`Unexpected mixed usage of different slot syntaxes`).toHaveBeenWarned()
       })
+    }
 
-      it('dynamic slot name', () => {
-        const vm = new Vue({
-          data: {
-            a: 'one',
-            b: 'two'
-          },
-          template: `
-            <foo>
-              <template #[a]="one">{{ one }} </template>
-              <template v-slot:[b]="two">{{ two }}</template>
-            </foo>
-          `,
-          components: { Foo }
-        }).$mount()
-        expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one from foo two`)
-      })
+    // run tests for both full syntax and shorthand
+    runSuite('v-slot')
+    runSuite('#default')
+
+    it('shorthand named slots', () => {
+      const vm = new Vue({
+        template: `
+          <foo>
+            <template #default="foo">
+              {{ foo }}
+            </template>
+            <template #one="one">
+              {{ one }}
+            </template>
+            <template #two="two">
+              {{ two }}
+            </template>
+          </foo>
+        `,
+        components: { Foo }
+      }).$mount()
+      expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo default from foo one from foo two`)
+    })
+
+    it('should warn mixed root-default and named slots', () => {
+      const vm = new Vue({
+        template: `
+          <foo #default="foo">
+            {{ foo }}
+            <template #one="one">
+              {{ one }}
+            </template>
+          </foo>
+        `,
+        components: { Foo }
+      }).$mount()
+      expect(`default slot should also use <template>`).toHaveBeenWarned()
+    })
+
+    it('shorthand without scope variable', () => {
+      const vm = new Vue({
+        template: `
+          <foo>
+            <template #one>one</template>
+            <template #two>two</template>
+          </foo>
+        `,
+        components: { Foo }
+      }).$mount()
+      expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`onetwo`)
+    })
+
+    it('shorthand named slots on root', () => {
+      const vm = new Vue({
+        template: `
+          <foo #one="one">
+            {{ one }}
+          </foo>
+        `,
+        components: { Foo }
+      }).$mount()
+      expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`from foo one`)
     })
-  }
+
+    it('dynamic slot name', done => {
+      const vm = new Vue({
+        data: {
+          a: 'one',
+          b: 'two'
+        },
+        template: `
+          <foo>
+            <template #[a]="one">a {{ one }} </template>
+            <template v-slot:[b]="two">b {{ two }} </template>
+          </foo>
+        `,
+        components: { Foo }
+      }).$mount()
+      expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`a from foo one b from foo two`)
+      vm.a = 'two'
+      vm.b = 'one'
+      waitForUpdate(() => {
+        expect(vm.$el.innerHTML.replace(/\s+/g, ' ')).toMatch(`b from foo one a from foo two `)
+      }).then(done)
+    })
+  })
+
+  // 2.6 scoped slot perf optimization
+  it('should have accurate tracking for scoped slots', done => {
+    const parentUpdate = jasmine.createSpy()
+    const childUpdate = jasmine.createSpy()
+    const vm = new Vue({
+      template: `
+        <div>{{ parentCount }}<foo #default>{{ childCount }}</foo></div>
+      `,
+      data: {
+        parentCount: 0,
+        childCount: 0
+      },
+      updated: parentUpdate,
+      components: {
+        foo: {
+          template: `<div><slot/></div>`,
+          updated: childUpdate
+        }
+      }
+    }).$mount()
+    expect(vm.$el.innerHTML).toMatch(`0<div>0</div>`)
+
+    vm.parentCount++
+    waitForUpdate(() => {
+      expect(vm.$el.innerHTML).toMatch(`1<div>0</div>`)
+      // should only trigger parent update
+      expect(parentUpdate.calls.count()).toBe(1)
+      expect(childUpdate.calls.count()).toBe(0)
+
+      vm.childCount++
+    }).then(() => {
+      expect(vm.$el.innerHTML).toMatch(`1<div>1</div>`)
+      // should only trigger child update
+      expect(parentUpdate.calls.count()).toBe(1)
+      expect(childUpdate.calls.count()).toBe(1)
+    }).then(done)
+  })
 })
diff --git a/test/unit/modules/compiler/codegen.spec.js b/test/unit/modules/compiler/codegen.spec.js
index 7f5fbeae333..d857ecbcf02 100644
--- a/test/unit/modules/compiler/codegen.spec.js
+++ b/test/unit/modules/compiler/codegen.spec.js
@@ -229,6 +229,13 @@ describe('codegen', () => {
     )
   })
 
+  it('generate dynamic scoped slot', () => {
+    assertCodegen(
+      '<foo><template :slot="foo" slot-scope="bar">{{ bar }}</template></foo>',
+      `with(this){return _c('foo',{scopedSlots:_u([{key:foo,fn:function(bar){return [_v(_s(bar))]}}],true)})}`
+    )
+  })
+
   it('generate scoped slot with multiline v-if', () => {
     assertCodegen(
       '<foo><template v-if="\nshow\n" slot-scope="bar">{{ bar }}</template></foo>',