Skip to content

Commit 6eb3399

Browse files
committed
fix(runtime-dom): fix patching for attributes starting with on
fix #949 BREAKING CHANGE: Only props starting with `on` followed by an uppercase letter or a non-letter character are considered event listeners.
1 parent 55566e8 commit 6eb3399

File tree

8 files changed

+72
-58
lines changed

8 files changed

+72
-58
lines changed

packages/runtime-core/__tests__/componentEmits.spec.ts

+9-5
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,27 @@ import { isEmitListener } from '../src/componentEmits'
88
describe('component: emit', () => {
99
mockWarn()
1010

11-
test('trigger both raw event and capitalize handlers', () => {
11+
test('trigger handlers', () => {
1212
const Foo = defineComponent({
1313
render() {},
1414
created() {
1515
// the `emit` function is bound on component instances
1616
this.$emit('foo')
1717
this.$emit('bar')
18+
this.$emit('!baz')
1819
}
1920
})
2021

2122
const onfoo = jest.fn()
2223
const onBar = jest.fn()
23-
const Comp = () => h(Foo, { onfoo, onBar })
24+
const onBaz = jest.fn()
25+
const Comp = () => h(Foo, { onfoo, onBar, ['on!baz']: onBaz })
2426
render(h(Comp), nodeOps.createElement('div'))
2527

26-
expect(onfoo).toHaveBeenCalled()
28+
expect(onfoo).not.toHaveBeenCalled()
29+
// only capitalized or special chars are considerd event listeners
2730
expect(onBar).toHaveBeenCalled()
31+
expect(onBaz).toHaveBeenCalled()
2832
})
2933

3034
// for v-model:foo-bar usage in DOM templates
@@ -125,9 +129,9 @@ describe('component: emit', () => {
125129

126130
test('isEmitListener', () => {
127131
expect(isEmitListener(['click'], 'onClick')).toBe(true)
128-
expect(isEmitListener(['click'], 'onclick')).toBe(true)
132+
expect(isEmitListener(['click'], 'onclick')).toBe(false)
129133
expect(isEmitListener({ click: null }, 'onClick')).toBe(true)
130-
expect(isEmitListener({ click: null }, 'onclick')).toBe(true)
134+
expect(isEmitListener({ click: null }, 'onclick')).toBe(false)
131135
expect(isEmitListener(['click'], 'onBlick')).toBe(false)
132136
expect(isEmitListener({ click: null }, 'onBlick')).toBe(false)
133137
})

packages/runtime-core/src/componentEmits.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ export function emit(
6666
}
6767
}
6868

69-
let handler = props[`on${event}`] || props[`on${capitalize(event)}`]
69+
let handler = props[`on${capitalize(event)}`]
7070
// for v-model update:xxx events, also trigger kebab-case equivalent
7171
// for props passed via kebab-case
7272
if (!handler && event.indexOf('update:') === 0) {
7373
event = hyphenate(event)
74-
handler = props[`on${event}`] || props[`on${capitalize(event)}`]
74+
handler = props[`on${capitalize(event)}`]
7575
}
7676
if (handler) {
7777
callWithAsyncErrorHandling(
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
1-
import { patchAttr, xlinkNS } from '../../src/modules/attrs'
1+
import { patchProp } from '../../src/patchProp'
2+
import { xlinkNS } from '../../src/modules/attrs'
23

34
describe('attrs', () => {
45
test('xlink attributes', () => {
56
const el = document.createElementNS('http://www.w3.org/2000/svg', 'use')
6-
patchAttr(el, 'xlink:href', 'a', true)
7+
patchProp(el, 'xlink:href', null, 'a', true)
78
expect(el.getAttributeNS(xlinkNS, 'href')).toBe('a')
8-
patchAttr(el, 'xlink:href', null, true)
9+
patchProp(el, 'xlink:href', 'a', null, true)
910
expect(el.getAttributeNS(xlinkNS, 'href')).toBe(null)
1011
})
1112

1213
test('boolean attributes', () => {
1314
const el = document.createElement('input')
14-
patchAttr(el, 'readonly', true, false)
15+
patchProp(el, 'readonly', null, true)
1516
expect(el.getAttribute('readonly')).toBe('')
16-
patchAttr(el, 'readonly', false, false)
17+
patchProp(el, 'readonly', true, false)
1718
expect(el.getAttribute('readonly')).toBe(null)
1819
})
1920

2021
test('attributes', () => {
2122
const el = document.createElement('div')
22-
patchAttr(el, 'id', 'a', false)
23-
expect(el.getAttribute('id')).toBe('a')
24-
patchAttr(el, 'id', null, false)
25-
expect(el.getAttribute('id')).toBe(null)
23+
patchProp(el, 'foo', null, 'a')
24+
expect(el.getAttribute('foo')).toBe('a')
25+
patchProp(el, 'foo', 'a', null)
26+
expect(el.getAttribute('foo')).toBe(null)
27+
})
28+
29+
// #949
30+
test('onxxx but non-listener attributes', () => {
31+
const el = document.createElement('div')
32+
patchProp(el, 'onwards', null, 'a')
33+
expect(el.getAttribute('onwards')).toBe('a')
34+
patchProp(el, 'onwards', 'a', null)
35+
expect(el.getAttribute('onwards')).toBe(null)
2636
})
2737
})

packages/runtime-dom/__tests__/modules/events.spec.ts

+20-18
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { patchEvent } from '../../src/modules/events'
1+
import { patchProp } from '../../src/patchProp'
22

33
const timeout = () => new Promise(r => setTimeout(r))
44

@@ -7,7 +7,7 @@ describe(`events`, () => {
77
const el = document.createElement('div')
88
const event = new Event('click')
99
const fn = jest.fn()
10-
patchEvent(el, 'onClick', null, fn, null)
10+
patchProp(el, 'onClick', null, fn)
1111
el.dispatchEvent(event)
1212
await timeout()
1313
el.dispatchEvent(event)
@@ -22,9 +22,9 @@ describe(`events`, () => {
2222
const event = new Event('click')
2323
const prevFn = jest.fn()
2424
const nextFn = jest.fn()
25-
patchEvent(el, 'onClick', null, prevFn, null)
25+
patchProp(el, 'onClick', null, prevFn)
2626
el.dispatchEvent(event)
27-
patchEvent(el, 'onClick', prevFn, nextFn, null)
27+
patchProp(el, 'onClick', prevFn, nextFn)
2828
await timeout()
2929
el.dispatchEvent(event)
3030
await timeout()
@@ -39,7 +39,7 @@ describe(`events`, () => {
3939
const event = new Event('click')
4040
const fn1 = jest.fn()
4141
const fn2 = jest.fn()
42-
patchEvent(el, 'onClick', null, [fn1, fn2], null)
42+
patchProp(el, 'onClick', null, [fn1, fn2])
4343
el.dispatchEvent(event)
4444
await timeout()
4545
expect(fn1).toHaveBeenCalledTimes(1)
@@ -50,8 +50,8 @@ describe(`events`, () => {
5050
const el = document.createElement('div')
5151
const event = new Event('click')
5252
const fn = jest.fn()
53-
patchEvent(el, 'onClick', null, fn, null)
54-
patchEvent(el, 'onClick', fn, null, null)
53+
patchProp(el, 'onClick', null, fn)
54+
patchProp(el, 'onClick', fn, null)
5555
el.dispatchEvent(event)
5656
await timeout()
5757
expect(fn).not.toHaveBeenCalled()
@@ -67,7 +67,7 @@ describe(`events`, () => {
6767
once: true
6868
}
6969
}
70-
patchEvent(el, 'onClick', null, nextValue, null)
70+
patchProp(el, 'onClick', null, nextValue)
7171
el.dispatchEvent(event)
7272
await timeout()
7373
el.dispatchEvent(event)
@@ -86,8 +86,8 @@ describe(`events`, () => {
8686
once: true
8787
}
8888
}
89-
patchEvent(el, 'onClick', null, prevFn, null)
90-
patchEvent(el, 'onClick', prevFn, nextValue, null)
89+
patchProp(el, 'onClick', null, prevFn)
90+
patchProp(el, 'onClick', prevFn, nextValue)
9191
el.dispatchEvent(event)
9292
await timeout()
9393
el.dispatchEvent(event)
@@ -106,30 +106,32 @@ describe(`events`, () => {
106106
once: true
107107
}
108108
}
109-
patchEvent(el, 'onClick', null, nextValue, null)
110-
patchEvent(el, 'onClick', nextValue, null, null)
109+
patchProp(el, 'onClick', null, nextValue)
110+
patchProp(el, 'onClick', nextValue, null)
111111
el.dispatchEvent(event)
112112
await timeout()
113113
el.dispatchEvent(event)
114114
await timeout()
115115
expect(fn).not.toHaveBeenCalled()
116116
})
117117

118-
it('should assign native onclick attribute', async () => {
118+
it('should support native onclick', async () => {
119119
const el = document.createElement('div')
120120
const event = new Event('click')
121-
const fn = ((window as any)._nativeClickSpy = jest.fn())
122121

123-
patchEvent(el, 'onclick', null, '_nativeClickSpy()' as any)
122+
// string should be set as attribute
123+
const fn = ((window as any).__globalSpy = jest.fn())
124+
patchProp(el, 'onclick', null, '__globalSpy(1)')
124125
el.dispatchEvent(event)
125126
await timeout()
126-
expect(fn).toHaveBeenCalledTimes(1)
127+
delete (window as any).__globalSpy
128+
expect(fn).toHaveBeenCalledWith(1)
127129

128130
const fn2 = jest.fn()
129-
patchEvent(el, 'onclick', null, fn2)
131+
patchProp(el, 'onclick', '__globalSpy(1)', fn2)
130132
el.dispatchEvent(event)
131133
await timeout()
132134
expect(fn).toHaveBeenCalledTimes(1)
133-
expect(fn2).toHaveBeenCalledTimes(1)
135+
expect(fn2).toHaveBeenCalledWith(event)
134136
})
135137
})

packages/runtime-dom/__tests__/modules/style.spec.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,47 @@
1-
import { patchStyle } from '../../src/modules/style'
1+
import { patchProp } from '../../src/patchProp'
22

33
describe(`module style`, () => {
44
it('string', () => {
55
const el = document.createElement('div')
6-
patchStyle(el, {}, 'color:red')
6+
patchProp(el, 'style', {}, 'color:red')
77
expect(el.style.cssText.replace(/\s/g, '')).toBe('color:red;')
88
})
99

1010
it('plain object', () => {
1111
const el = document.createElement('div')
12-
patchStyle(el, {}, { color: 'red' })
12+
patchProp(el, 'style', {}, { color: 'red' })
1313
expect(el.style.cssText.replace(/\s/g, '')).toBe('color:red;')
1414
})
1515

1616
it('camelCase', () => {
1717
const el = document.createElement('div')
18-
patchStyle(el, {}, { marginRight: '10px' })
18+
patchProp(el, 'style', {}, { marginRight: '10px' })
1919
expect(el.style.cssText.replace(/\s/g, '')).toBe('margin-right:10px;')
2020
})
2121

2222
it('remove if falsy value', () => {
2323
const el = document.createElement('div')
24-
patchStyle(el, { color: 'red' }, { color: undefined })
24+
patchProp(el, 'style', { color: 'red' }, { color: undefined })
2525
expect(el.style.cssText.replace(/\s/g, '')).toBe('')
2626
})
2727

2828
it('!important', () => {
2929
const el = document.createElement('div')
30-
patchStyle(el, {}, { color: 'red !important' })
30+
patchProp(el, 'style', {}, { color: 'red !important' })
3131
expect(el.style.cssText.replace(/\s/g, '')).toBe('color:red!important;')
3232
})
3333

3434
it('camelCase with !important', () => {
3535
const el = document.createElement('div')
36-
patchStyle(el, {}, { marginRight: '10px !important' })
36+
patchProp(el, 'style', {}, { marginRight: '10px !important' })
3737
expect(el.style.cssText.replace(/\s/g, '')).toBe(
3838
'margin-right:10px!important;'
3939
)
4040
})
4141

4242
it('object with multiple entries', () => {
4343
const el = document.createElement('div')
44-
patchStyle(el, {}, { color: 'red', marginRight: '10px' })
44+
patchProp(el, 'style', {}, { color: 'red', marginRight: '10px' })
4545
expect(el.style.getPropertyValue('color')).toBe('red')
4646
expect(el.style.getPropertyValue('margin-right')).toBe('10px')
4747
})
@@ -65,13 +65,13 @@ describe(`module style`, () => {
6565

6666
it('CSS custom properties', () => {
6767
const el = mockElementWithStyle()
68-
patchStyle(el as any, {}, { '--theme': 'red' } as any)
68+
patchProp(el as any, 'style', {}, { '--theme': 'red' } as any)
6969
expect(el.style.getPropertyValue('--theme')).toBe('red')
7070
})
7171

7272
it('auto vendor prefixing', () => {
7373
const el = mockElementWithStyle()
74-
patchStyle(el as any, {}, { transition: 'all 1s' })
74+
patchProp(el as any, 'style', {}, { transition: 'all 1s' })
7575
expect(el.style.WebkitTransition).toBe('all 1s')
7676
})
7777
})

packages/runtime-dom/src/modules/events.ts

+1-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EMPTY_OBJ, isString } from '@vue/shared'
1+
import { EMPTY_OBJ } from '@vue/shared'
22
import {
33
ComponentInternalInstance,
44
callWithAsyncErrorHandling
@@ -71,16 +71,6 @@ export function patchEvent(
7171
nextValue: EventValueWithOptions | EventValue | null,
7272
instance: ComponentInternalInstance | null = null
7373
) {
74-
// support native onxxx handlers
75-
if (rawName in el) {
76-
if (isString(nextValue)) {
77-
el.setAttribute(rawName, nextValue)
78-
} else {
79-
;(el as any)[rawName] = nextValue
80-
}
81-
return
82-
}
83-
8474
const name = rawName.slice(2).toLowerCase()
8575
const prevOptions = prevValue && 'options' in prevValue && prevValue.options
8676
const nextOptions = nextValue && 'options' in nextValue && nextValue.options

packages/runtime-dom/src/patchProp.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { patchStyle } from './modules/style'
33
import { patchAttr } from './modules/attrs'
44
import { patchDOMProp } from './modules/props'
55
import { patchEvent } from './modules/events'
6-
import { isOn } from '@vue/shared'
6+
import { isOn, isString } from '@vue/shared'
77
import { RendererOptions } from '@vue/runtime-core'
88

9+
const nativeOnRE = /^on[a-z]/
10+
911
export const patchProp: RendererOptions<Node, Element>['patchProp'] = (
1012
el,
1113
key,
@@ -31,7 +33,12 @@ export const patchProp: RendererOptions<Node, Element>['patchProp'] = (
3133
if (key.indexOf('onUpdate:') < 0) {
3234
patchEvent(el, key, prevValue, nextValue, parentComponent)
3335
}
34-
} else if (!isSVG && key in el) {
36+
} else if (
37+
!isSVG &&
38+
key in el &&
39+
// onclick="foo" needs to be set as an attribute to work
40+
!(nativeOnRE.test(key) && isString(nextValue))
41+
) {
3542
patchDOMProp(
3643
el,
3744
key,

packages/shared/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export const NOOP = () => {}
2424
*/
2525
export const NO = () => false
2626

27-
export const isOn = (key: string) => key[0] === 'o' && key[1] === 'n'
27+
const onRE = /^on[^a-z]/
28+
export const isOn = (key: string) => onRE.test(key)
2829

2930
export const extend = <T extends object, U extends object>(
3031
a: T,

0 commit comments

Comments
 (0)