mirror of https://github.com/vuejs/core.git
fix(runtime-dom): ensure element attributes are always properly applied regardless of being passed in kebap-case or camelCase.
fix: #5477
This commit is contained in:
parent
fe77e2bdda
commit
b8c330db5c
|
@ -0,0 +1,43 @@
|
||||||
|
# Fixing problems with hyphenated vs. camelCased element props
|
||||||
|
|
||||||
|
## Situation
|
||||||
|
|
||||||
|
Vue has two different ways of applying the vnode's props to an element:
|
||||||
|
1. apply them as element attributes (`el.settAttribute('value', 'Test')`)
|
||||||
|
2. apply them as element properties (`el.value = 'Test'`)
|
||||||
|
|
||||||
|
Vue prefers the second way *if* it can detect that property on the element (simplified: ` if (value in el)`, plus some exceptions/special cases.)
|
||||||
|
|
||||||
|
As no* regular HTML attribute contains a hyphen, kebab-case vs. camelCase is usually not an issue, but there are two important exceptions:
|
||||||
|
|
||||||
|
- all `aria-` attributes.
|
||||||
|
- any custom Attributes can contain hyphens (or be camelCased element properties). These are usually used on custom elements ("web components")
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
When a hyphenated or camelCased vnode prop is processed in `patchProp`, we can experience a few related but distinct undesirable bugs.
|
||||||
|
|
||||||
|
|prop|Has DOM prop?|handled correctly?|behavior
|
||||||
|
|-|-|-|-|
|
||||||
|
|`id`|✅|✅|applied as DOM property `id`|
|
||||||
|
|`aria-label`|❌|✅|applied as attribute `aria-label`|
|
||||||
|
|`ariaControls`|❌ |❌| 🚸 applied as attribute `ariacontrols` (1)|
|
||||||
|
|`custom-attr`|✅ `customAttr`|❌| 🚸 applied as attribute `custom-attr` (2a), though |
|
||||||
|
|`customAttr`|✅|✅| 🚸 applied as el property `customAttr` (2b)|
|
||||||
|
|
||||||
|
Problem (1):
|
||||||
|
|
||||||
|
a `camelCase` prop is applied as lowercase attribute (missing hyphen)
|
||||||
|
|
||||||
|
Problem (2):
|
||||||
|
|
||||||
|
- `kebap-case`prop applied as attribute even though matching camelCase DOM property exists.
|
||||||
|
- while `camelCase` prop is applied to element via the matching DOM property.
|
||||||
|
|
||||||
|
This can lead to problems with custom elements. For example, if the custom element's prop `post` expects a post object, that has to be passed as a DOMprop. Applying it as an attribute will result in `posts="[Object object]"`.
|
||||||
|
|
||||||
|
## Things things to consider / Open questions.
|
||||||
|
|
||||||
|
- SVGs can have `camelCase` attributes. That should be handled properly by the implementation, though - I think it's covered.
|
||||||
|
- `tabindex` attribute vs `tabIndex` DOMProp. Don't think this is a problem either but it feels worth mentioning as it's the only instance I can think of where a regular HTML attribute has a camelCase counterpart.
|
||||||
|
- `aria-haspopup` vs. `ariaHasPopup`: Chrome has the latter as a DOMProp (FF doesn't). That domProp's name is *not* the camelCase Version of the `aria-haspopup` attribute. Kinda like the tabindex situation.
|
|
@ -140,6 +140,44 @@ describe('runtime-dom: props patching', () => {
|
||||||
expect(fn).toHaveBeenCalled()
|
expect(fn).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('kebap-case vs. camelCase props', async () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
let _fooBar: string | undefined = undefined
|
||||||
|
Object.defineProperty(el, 'fooBar', {
|
||||||
|
get() {
|
||||||
|
return _fooBar
|
||||||
|
},
|
||||||
|
set(v: string | undefined) {
|
||||||
|
_fooBar = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Object.defineProperty(el, 'fooBaz', {
|
||||||
|
get() {
|
||||||
|
return _fooBar
|
||||||
|
},
|
||||||
|
set(v: string | undefined) {
|
||||||
|
_fooBar = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// existing DOMprops
|
||||||
|
patchProp(el, 'fooBar', null, 'foo')
|
||||||
|
expect(el.getAttribute('foobar')).toBe(null)
|
||||||
|
expect((el as any).fooBar).toBe('foo')
|
||||||
|
patchProp(el, 'foo-baz', null, 'baz')
|
||||||
|
expect(el.getAttribute('foobaz')).toBe(null)
|
||||||
|
expect((el as any).fooBar).toBe('baz')
|
||||||
|
|
||||||
|
// missing DOMProp
|
||||||
|
patchProp(el, 'ariaControls', null, 'someId')
|
||||||
|
expect('ariaControls' in el).toBe(false)
|
||||||
|
expect('aria-controls' in el).toBe(false)
|
||||||
|
expect(el.getAttribute('aria-controls')!).toBe('someId')
|
||||||
|
patchProp(el, 'aria-label', null, 'someId')
|
||||||
|
expect('aria-label' in el).toBe(false)
|
||||||
|
expect('ariaLabel' in el).toBe(false)
|
||||||
|
expect(el.getAttribute('aria-label')!).toBe('someId')
|
||||||
|
})
|
||||||
|
|
||||||
// #1049
|
// #1049
|
||||||
test('set value as-is for non string-value props', () => {
|
test('set value as-is for non string-value props', () => {
|
||||||
const el = document.createElement('video')
|
const el = document.createElement('video')
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
hyphenate,
|
||||||
includeBooleanAttr,
|
includeBooleanAttr,
|
||||||
isSpecialBooleanAttr,
|
isSpecialBooleanAttr,
|
||||||
makeMap,
|
makeMap,
|
||||||
|
@ -33,6 +34,7 @@ export function patchAttr(
|
||||||
// note we are only checking boolean attributes that don't have a
|
// note we are only checking boolean attributes that don't have a
|
||||||
// corresponding dom prop of the same name here.
|
// corresponding dom prop of the same name here.
|
||||||
const isBoolean = isSpecialBooleanAttr(key)
|
const isBoolean = isSpecialBooleanAttr(key)
|
||||||
|
key = isSVG ? key : hyphenate(key)
|
||||||
if (value == null || (isBoolean && !includeBooleanAttr(value))) {
|
if (value == null || (isBoolean && !includeBooleanAttr(value))) {
|
||||||
el.removeAttribute(key)
|
el.removeAttribute(key)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
// This can come from explicit usage of v-html or innerHTML as a prop in render
|
// This can come from explicit usage of v-html or innerHTML as a prop in render
|
||||||
|
|
||||||
import { warn, DeprecationTypes, compatUtils } from '@vue/runtime-core'
|
import { warn, DeprecationTypes, compatUtils } from '@vue/runtime-core'
|
||||||
import { includeBooleanAttr } from '@vue/shared'
|
import { camelize, includeBooleanAttr } from '@vue/shared'
|
||||||
|
|
||||||
// functions. The user is responsible for using them with only trusted content.
|
// functions. The user is responsible for using them with only trusted content.
|
||||||
export function patchDOMProp(
|
export function patchDOMProp(
|
||||||
|
@ -18,6 +18,7 @@ export function patchDOMProp(
|
||||||
parentSuspense: any,
|
parentSuspense: any,
|
||||||
unmountChildren: any
|
unmountChildren: any
|
||||||
) {
|
) {
|
||||||
|
key = camelize(key)
|
||||||
if (key === 'innerHTML' || key === 'textContent') {
|
if (key === 'innerHTML' || key === 'textContent') {
|
||||||
if (prevChildren) {
|
if (prevChildren) {
|
||||||
unmountChildren(prevChildren, parentComponent, parentSuspense)
|
unmountChildren(prevChildren, parentComponent, parentSuspense)
|
||||||
|
|
|
@ -3,7 +3,13 @@ import { patchStyle } from './modules/style'
|
||||||
import { patchAttr } from './modules/attrs'
|
import { patchAttr } from './modules/attrs'
|
||||||
import { patchDOMProp } from './modules/props'
|
import { patchDOMProp } from './modules/props'
|
||||||
import { patchEvent } from './modules/events'
|
import { patchEvent } from './modules/events'
|
||||||
import { isOn, isString, isFunction, isModelListener } from '@vue/shared'
|
import {
|
||||||
|
isOn,
|
||||||
|
isString,
|
||||||
|
isFunction,
|
||||||
|
isModelListener,
|
||||||
|
camelize
|
||||||
|
} from '@vue/shared'
|
||||||
import { RendererOptions } from '@vue/runtime-core'
|
import { RendererOptions } from '@vue/runtime-core'
|
||||||
|
|
||||||
const nativeOnRE = /^on[a-z]/
|
const nativeOnRE = /^on[a-z]/
|
||||||
|
@ -62,10 +68,11 @@ export const patchProp: DOMRendererOptions['patchProp'] = (
|
||||||
|
|
||||||
function shouldSetAsProp(
|
function shouldSetAsProp(
|
||||||
el: Element,
|
el: Element,
|
||||||
key: string,
|
key: string, // always camelized
|
||||||
value: unknown,
|
value: unknown,
|
||||||
isSVG: boolean
|
isSVG: boolean
|
||||||
) {
|
) {
|
||||||
|
key = key.includes('-') ? camelize(key) : key
|
||||||
if (isSVG) {
|
if (isSVG) {
|
||||||
// most keys must be set as attribute on svg elements to work
|
// most keys must be set as attribute on svg elements to work
|
||||||
// ...except innerHTML & textContent
|
// ...except innerHTML & textContent
|
||||||
|
|
Loading…
Reference in New Issue