CompilerOptions,
ErrorCodes,
NodeTypes,
- VNodeCall
+ VNodeCall,
+ helperNameMap,
+ CAPITALIZE
} from '../../src'
import { transformOn } from '../../src/transforms/vOn'
import { transformElement } from '../../src/transforms/transformElement'
{
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
- children: [`"on" + (`, { content: `event` }, `)`]
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: `event` },
+ `)`
+ ]
},
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
{
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
- children: [`"on" + (`, { content: `_ctx.event` }, `)`]
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: `_ctx.event` },
+ `)`
+ ]
},
value: {
type: NodeTypes.SIMPLE_EXPRESSION,
key: {
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
- `"on" + (`,
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
{ content: `_ctx.event` },
`(`,
{ content: `_ctx.foo` },
ElementNode,
ObjectExpression,
NodeTypes,
- VNodeCall
+ VNodeCall,
+ helperNameMap,
+ CAPITALIZE
} from '@vue/compiler-core'
import { transformOn } from '../../src/transforms/vOn'
import { V_ON_WITH_MODIFIERS, V_ON_WITH_KEYS } from '../../src/runtimeHelpers'
import { transformElement } from '../../../compiler-core/src/transforms/transformElement'
import { transformExpression } from '../../../compiler-core/src/transforms/transformExpression'
-import {
- createObjectMatcher,
- genFlagText
-} from '../../../compiler-core/__tests__/testUtils'
+import { genFlagText } from '../../../compiler-core/__tests__/testUtils'
import { PatchFlags } from '@vue/shared'
function parseWithVOn(template: string, options: CompilerOptions = {}) {
})
expect(prop).toMatchObject({
type: NodeTypes.JS_PROPERTY,
- value: createObjectMatcher({
- handler: {
- callee: V_ON_WITH_MODIFIERS,
- arguments: [{ content: '_ctx.test' }, '["stop"]']
- },
- options: createObjectMatcher({
- capture: { content: 'true', isStatic: false },
- passive: { content: 'true', isStatic: false }
- })
- })
+ key: {
+ content: `onClick.capture.passive`
+ },
+ value: {
+ callee: V_ON_WITH_MODIFIERS,
+ arguments: [{ content: '_ctx.test' }, '["stop"]']
+ }
})
})
it('should wrap keys guard for keyboard events or dynamic events', () => {
const {
props: [prop]
- } = parseWithVOn(`<div @keyDown.stop.capture.ctrl.a="test"/>`, {
+ } = parseWithVOn(`<div @keydown.stop.capture.ctrl.a="test"/>`, {
prefixIdentifiers: true
})
expect(prop).toMatchObject({
type: NodeTypes.JS_PROPERTY,
- value: createObjectMatcher({
- handler: {
- callee: V_ON_WITH_KEYS,
- arguments: [
- {
- callee: V_ON_WITH_MODIFIERS,
- arguments: [{ content: '_ctx.test' }, '["stop","ctrl"]']
- },
- '["a"]'
- ]
- },
- options: createObjectMatcher({
- capture: { content: 'true', isStatic: false }
- })
- })
+ key: {
+ content: `onKeydown.capture`
+ },
+ value: {
+ callee: V_ON_WITH_KEYS,
+ arguments: [
+ {
+ callee: V_ON_WITH_MODIFIERS,
+ arguments: [{ content: '_ctx.test' }, '["stop","ctrl"]']
+ },
+ '["a"]'
+ ]
+ }
})
})
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
`(`,
- { children: [`"on" + (`, { content: 'event' }, `)`] },
- `).toLowerCase() === "onclick" ? "onContextmenu" : (`,
- { children: [`"on" + (`, { content: 'event' }, `)`] },
+ {
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: 'event' },
+ `)`
+ ]
+ },
+ `) === "onClick" ? "onContextmenu" : (`,
+ {
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: 'event' },
+ `)`
+ ]
+ },
`)`
]
})
type: NodeTypes.COMPOUND_EXPRESSION,
children: [
`(`,
- { children: [`"on" + (`, { content: 'event' }, `)`] },
- `).toLowerCase() === "onclick" ? "onMouseup" : (`,
- { children: [`"on" + (`, { content: 'event' }, `)`] },
+ {
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: 'event' },
+ `)`
+ ]
+ },
+ `) === "onClick" ? "onMouseup" : (`,
+ {
+ children: [
+ `"on" + _${helperNameMap[CAPITALIZE]}(`,
+ { content: 'event' },
+ `)`
+ ]
+ },
`)`
]
})
expect((root as any).children[0].codegenNode.patchFlag).toBe(
genFlagText(PatchFlags.HYDRATE_EVENTS)
)
- expect(prop.value).toMatchObject({
- type: NodeTypes.JS_CACHE_EXPRESSION,
- index: 1,
+ expect(prop).toMatchObject({
+ key: {
+ content: `onKeyup.capture`
+ },
value: {
- type: NodeTypes.JS_OBJECT_EXPRESSION,
- properties: [
- {
- key: { content: 'handler' },
- value: {
- type: NodeTypes.JS_CALL_EXPRESSION,
- callee: V_ON_WITH_KEYS
- }
- },
- {
- key: { content: 'options' },
- value: { type: NodeTypes.JS_OBJECT_EXPRESSION }
- }
- ]
+ type: NodeTypes.JS_CACHE_EXPRESSION,
+ index: 1,
+ value: {
+ type: NodeTypes.JS_CALL_EXPRESSION,
+ callee: V_ON_WITH_KEYS
+ }
}
})
})
DirectiveTransform,
createObjectProperty,
createCallExpression,
- createObjectExpression,
createSimpleExpression,
NodeTypes,
createCompoundExpression,
? createCompoundExpression([
`(`,
key,
- `).toLowerCase() === "onclick" ? "${event}" : (`,
+ `) === "onClick" ? "${event}" : (`,
key,
`)`
])
}
if (eventOptionModifiers.length) {
- handlerExp = createObjectExpression([
- createObjectProperty('handler', handlerExp),
- createObjectProperty(
- 'options',
- createObjectExpression(
- eventOptionModifiers.map(modifier =>
- createObjectProperty(
- modifier,
- createSimpleExpression('true', false)
- )
- )
+ key = isStaticExp(key)
+ ? createSimpleExpression(
+ `${key.content}.${eventOptionModifiers.join(`.`)}`,
+ true
)
- )
- ])
+ : createCompoundExpression([
+ `(`,
+ key,
+ `) + ".${eventOptionModifiers.join(`.`)}"`
+ ])
}
return {
expect(`event validation failed for event "foo"`).toHaveBeenWarned()
})
- test('isEmitListener', () => {
- const def1 = { emits: ['click'] }
- expect(isEmitListener(def1, 'onClick')).toBe(true)
- expect(isEmitListener(def1, 'onclick')).toBe(false)
- expect(isEmitListener(def1, 'onBlick')).toBe(false)
-
- const def2 = { emits: { click: null } }
- expect(isEmitListener(def2, 'onClick')).toBe(true)
- expect(isEmitListener(def2, 'onclick')).toBe(false)
- expect(isEmitListener(def2, 'onBlick')).toBe(false)
-
- const mixin1 = { emits: ['foo'] }
- const mixin2 = { emits: ['bar'] }
- const extend = { emits: ['baz'] }
- const def3 = {
- emits: { click: null },
- mixins: [mixin1, mixin2],
- extends: extend
- }
- expect(isEmitListener(def3, 'onClick')).toBe(true)
- expect(isEmitListener(def3, 'onFoo')).toBe(true)
- expect(isEmitListener(def3, 'onBar')).toBe(true)
- expect(isEmitListener(def3, 'onBaz')).toBe(true)
- expect(isEmitListener(def3, 'onclick')).toBe(false)
- expect(isEmitListener(def3, 'onBlick')).toBe(false)
+ test('.once', () => {
+ const Foo = defineComponent({
+ render() {},
+ emits: {
+ foo: null
+ },
+ created() {
+ this.$emit('foo')
+ this.$emit('foo')
+ }
+ })
+ const fn = jest.fn()
+ render(
+ h(Foo, {
+ 'onFoo.once': fn
+ }),
+ nodeOps.createElement('div')
+ )
+ expect(fn).toHaveBeenCalledTimes(1)
+ })
+
+ describe('isEmitListener', () => {
+ test('array option', () => {
+ const def1 = { emits: ['click'] }
+ expect(isEmitListener(def1, 'onClick')).toBe(true)
+ expect(isEmitListener(def1, 'onclick')).toBe(false)
+ expect(isEmitListener(def1, 'onBlick')).toBe(false)
+ })
+
+ test('object option', () => {
+ const def2 = { emits: { click: null } }
+ expect(isEmitListener(def2, 'onClick')).toBe(true)
+ expect(isEmitListener(def2, 'onclick')).toBe(false)
+ expect(isEmitListener(def2, 'onBlick')).toBe(false)
+ })
+
+ test('with mixins and extends', () => {
+ const mixin1 = { emits: ['foo'] }
+ const mixin2 = { emits: ['bar'] }
+ const extend = { emits: ['baz'] }
+ const def3 = {
+ mixins: [mixin1, mixin2],
+ extends: extend
+ }
+ expect(isEmitListener(def3, 'onFoo')).toBe(true)
+ expect(isEmitListener(def3, 'onBar')).toBe(true)
+ expect(isEmitListener(def3, 'onBaz')).toBe(true)
+ expect(isEmitListener(def3, 'onclick')).toBe(false)
+ expect(isEmitListener(def3, 'onBlick')).toBe(false)
+ })
+
+ test('.once listeners', () => {
+ const def2 = { emits: { click: null } }
+ expect(isEmitListener(def2, 'onClick.once')).toBe(true)
+ expect(isEmitListener(def2, 'onclick.once')).toBe(false)
+ })
})
})
slots: InternalSlots
refs: Data
emit: EmitFn
+ // used for keeping track of .once event handlers on components
+ emitted: Record<string, boolean> | null
/**
* setup related
rtg: null,
rtc: null,
ec: null,
- emit: null as any // to be set immediately
+ emit: null as any, // to be set immediately
+ emitted: null
}
if (__DEV__) {
instance.ctx = createRenderContext(instance)
}
}
- let handler = props[`on${capitalize(event)}`]
+ let handlerName = `on${capitalize(event)}`
+ let handler = props[handlerName]
// for v-model update:xxx events, also trigger kebab-case equivalent
// for props passed via kebab-case
if (!handler && event.startsWith('update:')) {
- event = hyphenate(event)
- handler = props[`on${capitalize(event)}`]
+ handlerName = `on${capitalize(hyphenate(event))}`
+ handler = props[handlerName]
+ }
+ if (!handler) {
+ handler = props[handlerName + `.once`]
+ if (!instance.emitted) {
+ ;(instance.emitted = {} as Record<string, boolean>)[handlerName] = true
+ } else if (instance.emitted[handlerName]) {
+ return
+ }
}
if (handler) {
callWithAsyncErrorHandling(
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
// both considered matched listeners.
export function isEmitListener(comp: Component, key: string): boolean {
- if (!isOn(key)) {
+ let emits: ObjectEmitsOptions | undefined
+ if (!isOn(key) || !(emits = normalizeEmitsOptions(comp))) {
return false
}
- const emits = normalizeEmitsOptions(comp)
+ key = key.replace(/\.once$/, '')
return (
- !!emits &&
- (hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
- hasOwn(emits, key.slice(2)))
+ hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
+ hasOwn(emits, key.slice(2))
)
}
expect(fn).not.toHaveBeenCalled()
})
- it('should support event options', async () => {
+ it('should support event option modifiers', async () => {
const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn()
- const nextValue = {
- handler: fn,
- options: {
- once: true
- }
- }
- patchProp(el, 'onClick', null, nextValue)
+ patchProp(el, 'onClick.once.capture', null, fn)
el.dispatchEvent(event)
await timeout()
el.dispatchEvent(event)
expect(fn).toHaveBeenCalledTimes(1)
})
- it('should support varying event options', async () => {
- const el = document.createElement('div')
- const event = new Event('click')
- const prevFn = jest.fn()
- const nextFn = jest.fn()
- const nextValue = {
- handler: nextFn,
- options: {
- once: true
- }
- }
- patchProp(el, 'onClick', null, prevFn)
- patchProp(el, 'onClick', prevFn, nextValue)
- el.dispatchEvent(event)
- await timeout()
- el.dispatchEvent(event)
- await timeout()
- expect(prevFn).not.toHaveBeenCalled()
- expect(nextFn).toHaveBeenCalledTimes(1)
- })
-
it('should unassign event handler with options', async () => {
const el = document.createElement('div')
const event = new Event('click')
const fn = jest.fn()
- const nextValue = {
- handler: fn,
- options: {
- once: true
- }
- }
- patchProp(el, 'onClick', null, nextValue)
- patchProp(el, 'onClick', nextValue, null)
+ patchProp(el, 'onClick.capture', null, fn)
+ patchProp(el, 'onClick.capture', fn, null)
el.dispatchEvent(event)
await timeout()
el.dispatchEvent(event)
-import { EMPTY_OBJ, isArray } from '@vue/shared'
+import { isArray } from '@vue/shared'
import {
ComponentInternalInstance,
callWithAsyncErrorHandling
invoker?: Invoker | null
}
-type EventValueWithOptions = {
- handler: EventValue
- options: AddEventListenerOptions
- invoker?: Invoker | null
-}
-
// Async edge case fix requires storing an event listener's attach timestamp.
let _getNow: () => number = Date.now
export function patchEvent(
el: Element,
rawName: string,
- prevValue: EventValueWithOptions | EventValue | null,
- nextValue: EventValueWithOptions | EventValue | null,
+ prevValue: EventValue | null,
+ nextValue: EventValue | null,
instance: ComponentInternalInstance | null = null
) {
- const name = rawName.slice(2).toLowerCase()
- const prevOptions = prevValue && 'options' in prevValue && prevValue.options
- const nextOptions = nextValue && 'options' in nextValue && nextValue.options
const invoker = prevValue && prevValue.invoker
- const value =
- nextValue && 'handler' in nextValue ? nextValue.handler : nextValue
-
- if (prevOptions || nextOptions) {
- const prev = prevOptions || EMPTY_OBJ
- const next = nextOptions || EMPTY_OBJ
- if (
- prev.capture !== next.capture ||
- prev.passive !== next.passive ||
- prev.once !== next.once
- ) {
- if (invoker) {
- removeEventListener(el, name, invoker, prev)
- }
- if (nextValue && value) {
- const invoker = createInvoker(value, instance)
- nextValue.invoker = invoker
- addEventListener(el, name, invoker, next)
- }
- return
+ if (nextValue && invoker) {
+ // patch
+ ;(prevValue as EventValue).invoker = null
+ invoker.value = nextValue
+ nextValue.invoker = invoker
+ } else {
+ const [name, options] = parseName(rawName)
+ if (nextValue) {
+ addEventListener(el, name, createInvoker(nextValue, instance), options)
+ } else if (invoker) {
+ // remove
+ removeEventListener(el, name, invoker, options)
}
}
+}
- if (nextValue && value) {
- if (invoker) {
- ;(prevValue as EventValue).invoker = null
- invoker.value = value
- nextValue.invoker = invoker
- } else {
- addEventListener(
- el,
- name,
- createInvoker(value, instance),
- nextOptions || void 0
- )
- }
- } else if (invoker) {
- removeEventListener(el, name, invoker, prevOptions || void 0)
+const optionsModifierRE = /\.(once|passive|capture)\b/g
+
+function parseName(name: string): [string, EventListenerOptions | undefined] {
+ name = name.slice(2).toLowerCase()
+ if (optionsModifierRE.test(name)) {
+ const options: EventListenerOptions = {}
+ name = name.replace(
+ optionsModifierRE,
+ (_, key: keyof EventListenerOptions) => {
+ options[key] = true
+ return ''
+ }
+ )
+ return [name, options]
+ } else {
+ return [name, undefined]
}
}