Repository: vuejs/vue
Pull request: Modifier once for v-on

<!--
Please make sure to read the Pull Request Guidelines:
https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md#pull-request-guidelines
-->

Modifier once for v-on is added.

Changed files:
filename: src/compiler/helpers.js
patch: @@ -46,6 +46,10 @@ export function addHandler (
     delete modifiers.capture
     name = '!' + name // mark the event as captured
   }
+  if (modifiers && modifiers.once) {
+    delete modifiers.once
+    name = '~' + name // mark the event as once
+  }
   let events
   if (modifiers && modifiers.native) {
     delete modifiers.native

filename: src/core/vdom/helpers/update-listeners.js
patch: @@ -9,7 +9,7 @@ export function updateListeners (
   remove: Function,
   vm: Component
 ) {
-  let name, cur, old, fn, event, capture
+  let name, cur, old, fn, event, capture, once
   for (name in on) {
     cur = on[name]
     old = oldOn[name]
@@ -19,18 +19,20 @@ export function updateListeners (
         vm
       )
     } else if (!old) {
-      capture = name.charAt(0) === '!'
-      event = capture ? name.slice(1) : name
+      once = name.charAt(0) === '~' // Prefixed last, checked first
+      event = once ? name.slice(1) : name
+      capture = event.charAt(0) === '!'
+      event = capture ? event.slice(1) : event
       if (Array.isArray(cur)) {
-        add(event, (cur.invoker = arrInvoker(cur)), capture)
+        add(event, (cur.invoker = arrInvoker(cur)), capture, once)
       } else {
         if (!cur.invoker) {
           fn = cur
           cur = on[name] = {}
           cur.fn = fn
           cur.invoker = fnInvoker(cur)
         }
-        add(event, cur.invoker, capture)
+        add(event, cur.invoker, capture, once)
       }
     } else if (cur !== old) {
       if (Array.isArray(old)) {
@@ -45,8 +47,11 @@ export function updateListeners (
   }
   for (name in oldOn) {
     if (!on[name]) {
-      event = name.charAt(0) === '!' ? name.slice(1) : name
-      remove(event, oldOn[name].invoker)
+      once = name.charAt(0) === '~' // Prefixed last, checked first
+      event = once ? name.slice(1) : name
+      capture = event.charAt(0) === '!'
+      event = capture ? event.slice(1) : event
+      remove(event, oldOn[name].invoker, capture) // Removal of a capturing listener does not affect a non-capturing version of the same listener, and vice versa.
     }
   }
 }

filename: src/platforms/web/runtime/modules/events.js
patch: @@ -9,11 +9,19 @@ function updateDOMListeners (oldVnode, vnode) {
   }
   const on = vnode.data.on || {}
   const oldOn = oldVnode.data.on || {}
-  const add = vnode.elm._v_add || (vnode.elm._v_add = (event, handler, capture) => {
+  const add = vnode.elm._v_add || (vnode.elm._v_add = (event, handler, capture, once) => {
+    if (once) {
+      const oldHandler = handler
+      handler = function (ev) {
+        remove(event, handler, capture)
+
+        arguments.length === 1 ? oldHandler(ev) : oldHandler.apply(null, arguments)
+      }
+    }
     vnode.elm.addEventListener(event, handler, capture)
   })
-  const remove = vnode.elm._v_remove || (vnode.elm._v_remove = (event, handler) => {
-    vnode.elm.removeEventListener(event, handler)
+  const remove = vnode.elm._v_remove || (vnode.elm._v_remove = (event, handler, capture) => {
+    vnode.elm.removeEventListener(event, handler, capture)
   })
   updateListeners(on, oldOn, add, remove, vnode.context)
 }

filename: test/unit/features/directives/on.spec.js
patch: @@ -115,6 +115,41 @@ describe('Directive v-on', () => {
     expect(callOrder.toString()).toBe('1,2')
   })
 
+  it('should support once', () => {
+    vm = new Vue({
+      el,
+      template: `
+        <div @click.once="foo">
+        </div>
+      `,
+      methods: { foo: spy }
+    })
+    triggerEvent(vm.$el, 'click')
+    expect(spy.calls.count()).toBe(1)
+    triggerEvent(vm.$el, 'click')
+    expect(spy.calls.count()).toBe(1) // should no longer trigger
+  })
+
+  it('should support capture and once', () => {
+    const callOrder = []
+    vm = new Vue({
+      el,
+      template: `
+        <div @click.capture.once="foo">
+          <div @click="bar"></div>
+        </div>
+      `,
+      methods: {
+        foo () { callOrder.push(1) },
+        bar () { callOrder.push(2) }
+      }
+    })
+    triggerEvent(vm.$el.firstChild, 'click')
+    expect(callOrder.toString()).toBe('1,2')
+    triggerEvent(vm.$el.firstChild, 'click')
+    expect(callOrder.toString()).toBe('1,2,2')
+  })
+
   it('should support keyCode', () => {
     vm = new Vue({
       el,
@@ -206,6 +241,88 @@ describe('Directive v-on', () => {
     }).then(done)
   })
 
+  it('remove capturing listener', done => {
+    const spy2 = jasmine.createSpy('remove listener')
+    vm = new Vue({
+      el,
+      methods: { foo: spy, bar: spy2, stopped (ev) { ev.stopPropagation() } },
+      data: {
+        ok: true
+      },
+      render (h) {
+        return this.ok
+          ? h('div', { on: { '!click': this.foo }}, [h('div', { on: { click: this.stopped }})])
+          : h('div', { on: { mouseOver: this.bar }}, [h('div')])
+      }
+    })
+    triggerEvent(vm.$el.firstChild, 'click')
+    expect(spy.calls.count()).toBe(1)
+    expect(spy2.calls.count()).toBe(0)
+    vm.ok = false
+    waitForUpdate(() => {
+      triggerEvent(vm.$el.firstChild, 'click')
+      expect(spy.calls.count()).toBe(1) // should no longer trigger
+      triggerEvent(vm.$el, 'mouseOver')
+      expect(spy2.calls.count()).toBe(1)
+    }).then(done)
+  })
+
+  it('remove once listener', done => {
+    const spy2 = jasmine.createSpy('remove listener')
+    vm = new Vue({
+      el,
+      methods: { foo: spy, bar: spy2 },
+      data: {
+        ok: true
+      },
+      render (h) {
+        return this.ok
+          ? h('input', { on: { '~click': this.foo }})
+          : h('input', { on: { input: this.bar }})
+      }
+    })
+    triggerEvent(vm.$el, 'click')
+    expect(spy.calls.count()).toBe(1)
+    triggerEvent(vm.$el, 'click')
+    expect(spy.calls.count()).toBe(1) // should no longer trigger
+    expect(spy2.calls.count()).toBe(0)
+    vm.ok = false
+    waitForUpdate(() => {
+      triggerEvent(vm.$el, 'click')
+      expect(spy.calls.count()).toBe(1) // should no longer trigger
+      triggerEvent(vm.$el, 'input')
+      expect(spy2.calls.count()).toBe(1)
+    }).then(done)
+  })
+
+  it('remove capturing and once listener', done => {
+    const spy2 = jasmine.createSpy('remove listener')
+    vm = new Vue({
+      el,
+      methods: { foo: spy, bar: spy2, stopped (ev) { ev.stopPropagation() } },
+      data: {
+        ok: true
+      },
+      render (h) {
+        return this.ok
+          ? h('div', { on: { '~!click': this.foo }}, [h('div', { on: { click: this.stopped }})])
+          : h('div', { on: { mouseOver: this.bar }}, [h('div')])
+      }
+    })
+    triggerEvent(vm.$el.firstChild, 'click')
+    expect(spy.calls.count()).toBe(1)
+    triggerEvent(vm.$el.firstChild, 'click')
+    expect(spy.calls.count()).toBe(1) // should no longer trigger
+    expect(spy2.calls.count()).toBe(0)
+    vm.ok = false
+    waitForUpdate(() => {
+      triggerEvent(vm.$el.firstChild, 'click')
+      expect(spy.calls.count()).toBe(1) // should no longer trigger
+      triggerEvent(vm.$el, 'mouseOver')
+      expect(spy2.calls.count()).toBe(1)
+    }).then(done)
+  })
+
   it('remove listener on child component', done => {
     const spy2 = jasmine.createSpy('remove listener')
     vm = new Vue({

filename: test/unit/modules/compiler/codegen.spec.js
patch: @@ -296,6 +296,27 @@ describe('codegen', () => {
     )
   })
 
+  it('generate events with once modifier', () => {
+    assertCodegen(
+      '<input @input.once="onInput">',
+      `with(this){return _h('input',{on:{"~input":function($event){onInput($event)}}})}`
+    )
+  })
+
+  it('generate events with capture and once modifier', () => {
+    assertCodegen(
+      '<input @input.capture.once="onInput">',
+      `with(this){return _h('input',{on:{"~!input":function($event){onInput($event)}}})}`
+    )
+  })
+
+  it('generate events with once and capture modifier', () => {
+    assertCodegen(
+      '<input @input.once.capture="onInput">',
+      `with(this){return _h('input',{on:{"~!input":function($event){onInput($event)}}})}`
+    )
+  })
+
   it('generate events with inline statement', () => {
     assertCodegen(
       '<input @input="curent++">',