patchProps.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { patchProp } from '../src/patchProp'
  2. import {
  3. h,
  4. nextTick,
  5. ref,
  6. render,
  7. vModelCheckbox,
  8. withDirectives,
  9. } from '../src'
  10. describe('runtime-dom: props patching', () => {
  11. test('basic', () => {
  12. const el = document.createElement('div')
  13. patchProp(el, 'id', null, 'foo')
  14. expect(el.id).toBe('foo')
  15. // prop with string value should be set to empty string on null values
  16. patchProp(el, 'id', null, null)
  17. expect(el.id).toBe('')
  18. expect(el.getAttribute('id')).toBe(null)
  19. })
  20. test('value', () => {
  21. const el = document.createElement('input')
  22. patchProp(el, 'value', null, 'foo')
  23. expect(el.value).toBe('foo')
  24. patchProp(el, 'value', null, null)
  25. expect(el.value).toBe('')
  26. expect(el.getAttribute('value')).toBe(null)
  27. const obj = {}
  28. patchProp(el, 'value', null, obj)
  29. expect(el.value).toBe(obj.toString())
  30. expect((el as any)._value).toBe(obj)
  31. const option = document.createElement('option')
  32. patchProp(option, 'textContent', null, 'foo')
  33. expect(option.value).toBe('foo')
  34. expect(option.getAttribute('value')).toBe(null)
  35. patchProp(option, 'value', null, 'foo')
  36. expect(option.value).toBe('foo')
  37. expect(option.getAttribute('value')).toBe('foo')
  38. })
  39. test('value for custom elements', () => {
  40. class TestElement extends HTMLElement {
  41. constructor() {
  42. super()
  43. }
  44. // intentionally uses _value because this is used in "normal" HTMLElement for storing the object of the set property value
  45. private _value: any
  46. get value() {
  47. return this._value
  48. }
  49. set value(val) {
  50. this._value = val
  51. this.setterCalled++
  52. }
  53. public setterCalled: number = 0
  54. }
  55. window.customElements.define('patch-props-test-element', TestElement)
  56. const el = document.createElement('patch-props-test-element') as TestElement
  57. patchProp(el, 'value', null, 'foo')
  58. expect(el.value).toBe('foo')
  59. expect(el.setterCalled).toBe(1)
  60. patchProp(el, 'value', null, null)
  61. expect(el.value).toBe('')
  62. expect(el.setterCalled).toBe(2)
  63. expect(el.getAttribute('value')).toBe(null)
  64. const obj = {}
  65. patchProp(el, 'value', null, obj)
  66. expect(el.value).toBe(obj)
  67. expect(el.setterCalled).toBe(3)
  68. })
  69. // For <input type="text">, setting el.value won't create a `value` attribute
  70. // so we need to add tests for other elements
  71. test('value for non-text input', () => {
  72. const el = document.createElement('option')
  73. el.textContent = 'foo' // #4956
  74. patchProp(el, 'value', null, 'foo')
  75. expect(el.getAttribute('value')).toBe('foo')
  76. expect(el.value).toBe('foo')
  77. patchProp(el, 'value', null, null)
  78. el.textContent = ''
  79. expect(el.value).toBe('')
  80. // #3475
  81. expect(el.getAttribute('value')).toBe(null)
  82. })
  83. test('boolean prop', () => {
  84. const el = document.createElement('select')
  85. patchProp(el, 'multiple', null, '')
  86. expect(el.multiple).toBe(true)
  87. patchProp(el, 'multiple', null, null)
  88. expect(el.multiple).toBe(false)
  89. patchProp(el, 'multiple', null, true)
  90. expect(el.multiple).toBe(true)
  91. patchProp(el, 'multiple', null, 0)
  92. expect(el.multiple).toBe(false)
  93. patchProp(el, 'multiple', null, '0')
  94. expect(el.multiple).toBe(true)
  95. patchProp(el, 'multiple', null, false)
  96. expect(el.multiple).toBe(false)
  97. patchProp(el, 'multiple', null, 1)
  98. expect(el.multiple).toBe(true)
  99. patchProp(el, 'multiple', null, undefined)
  100. expect(el.multiple).toBe(false)
  101. })
  102. test('innerHTML unmount prev children', () => {
  103. const fn = vi.fn()
  104. const comp = {
  105. render: () => 'foo',
  106. unmounted: fn,
  107. }
  108. const root = document.createElement('div')
  109. render(h('div', null, [h(comp)]), root)
  110. expect(root.innerHTML).toBe(`<div>foo</div>`)
  111. render(h('div', { innerHTML: 'bar' }), root)
  112. expect(root.innerHTML).toBe(`<div>bar</div>`)
  113. expect(fn).toHaveBeenCalled()
  114. })
  115. // #954
  116. test('(svg) innerHTML unmount prev children', () => {
  117. const fn = vi.fn()
  118. const comp = {
  119. render: () => 'foo',
  120. unmounted: fn,
  121. }
  122. const root = document.createElement('div')
  123. render(h('div', null, [h(comp)]), root)
  124. expect(root.innerHTML).toBe(`<div>foo</div>`)
  125. render(h('svg', { innerHTML: '<g></g>' }), root)
  126. expect(root.innerHTML).toBe(`<svg><g></g></svg>`)
  127. expect(fn).toHaveBeenCalled()
  128. })
  129. test('patch innerHTML porp', async () => {
  130. const root = document.createElement('div')
  131. const state = ref(false)
  132. const Comp = {
  133. render: () => {
  134. if (state.value) {
  135. return h('div', [h('del', null, 'baz')])
  136. } else {
  137. return h('div', { innerHTML: 'baz' })
  138. }
  139. },
  140. }
  141. render(h(Comp), root)
  142. expect(root.innerHTML).toBe(`<div>baz</div>`)
  143. state.value = true
  144. await nextTick()
  145. expect(root.innerHTML).toBe(`<div><del>baz</del></div>`)
  146. })
  147. test('patch innerHTML porp w/ undefined value', async () => {
  148. const root = document.createElement('div')
  149. render(h('div', { innerHTML: undefined }), root)
  150. expect(root.innerHTML).toBe(`<div></div>`)
  151. })
  152. test('textContent unmount prev children', () => {
  153. const fn = vi.fn()
  154. const comp = {
  155. render: () => 'foo',
  156. unmounted: fn,
  157. }
  158. const root = document.createElement('div')
  159. render(h('div', null, [h(comp)]), root)
  160. expect(root.innerHTML).toBe(`<div>foo</div>`)
  161. render(h('div', { textContent: 'bar' }), root)
  162. expect(root.innerHTML).toBe(`<div>bar</div>`)
  163. expect(fn).toHaveBeenCalled()
  164. })
  165. // #1049
  166. test('set value as-is for non string-value props', () => {
  167. const el = document.createElement('video')
  168. // jsdom doesn't really support video playback. srcObject in a real browser
  169. // should default to `null`, but in jsdom it's `undefined`.
  170. // anyway, here we just want to make sure Vue doesn't set non-string props
  171. // to an empty string on nullish values - it should reset to its default
  172. // value.
  173. el.srcObject = null
  174. const initialValue = el.srcObject
  175. const fakeObject = {}
  176. patchProp(el, 'srcObject', null, fakeObject)
  177. expect(el.srcObject).toBe(fakeObject)
  178. patchProp(el, 'srcObject', null, null)
  179. expect(el.srcObject).toBe(initialValue)
  180. })
  181. test('catch and warn prop set TypeError', () => {
  182. const el = document.createElement('div')
  183. Object.defineProperty(el, 'someProp', {
  184. set() {
  185. throw new TypeError('Invalid type')
  186. },
  187. })
  188. patchProp(el, 'someProp', null, 'foo')
  189. expect(`Failed setting prop "someProp" on <div>`).toHaveBeenWarnedLast()
  190. })
  191. // #1576
  192. test('remove attribute when value is falsy', () => {
  193. const el = document.createElement('div')
  194. patchProp(el, 'id', null, '')
  195. expect(el.hasAttribute('id')).toBe(true)
  196. patchProp(el, 'id', null, null)
  197. expect(el.hasAttribute('id')).toBe(false)
  198. patchProp(el, 'id', null, '')
  199. expect(el.hasAttribute('id')).toBe(true)
  200. patchProp(el, 'id', null, undefined)
  201. expect(el.hasAttribute('id')).toBe(false)
  202. patchProp(el, 'id', null, '')
  203. expect(el.hasAttribute('id')).toBe(true)
  204. // #2677
  205. const img = document.createElement('img')
  206. patchProp(img, 'width', null, '')
  207. expect(el.hasAttribute('width')).toBe(false)
  208. patchProp(img, 'width', null, 0)
  209. expect(img.hasAttribute('width')).toBe(true)
  210. patchProp(img, 'width', null, null)
  211. expect(img.hasAttribute('width')).toBe(false)
  212. patchProp(img, 'width', null, 0)
  213. expect(img.hasAttribute('width')).toBe(true)
  214. patchProp(img, 'width', null, undefined)
  215. expect(img.hasAttribute('width')).toBe(false)
  216. patchProp(img, 'width', null, 0)
  217. expect(img.hasAttribute('width')).toBe(true)
  218. })
  219. test('form attribute', () => {
  220. const el = document.createElement('input')
  221. patchProp(el, 'form', null, 'foo')
  222. // non existent element
  223. expect(el.form).toBe(null)
  224. expect(el.getAttribute('form')).toBe('foo')
  225. // remove attribute
  226. patchProp(el, 'form', 'foo', null)
  227. expect(el.getAttribute('form')).toBe(null)
  228. })
  229. test('readonly type prop on textarea', () => {
  230. const el = document.createElement('textarea')
  231. // just to verify that it doesn't throw when i.e. switching a dynamic :is from an 'input' to a 'textarea'
  232. // see https://github.com/vuejs/core/issues/2766
  233. patchProp(el, 'type', 'text', null)
  234. })
  235. test('force patch as prop', () => {
  236. const el = document.createElement('div') as any
  237. patchProp(el, '.x', null, 1)
  238. expect(el.x).toBe(1)
  239. })
  240. test('force patch as attribute', () => {
  241. const el = document.createElement('div') as any
  242. el.x = 1
  243. patchProp(el, '^x', null, 2)
  244. expect(el.x).toBe(1)
  245. expect(el.getAttribute('x')).toBe('2')
  246. })
  247. test('input with size (number property)', () => {
  248. const el = document.createElement('input')
  249. patchProp(el, 'size', null, 100)
  250. expect(el.size).toBe(100)
  251. patchProp(el, 'size', 100, null)
  252. expect(el.getAttribute('size')).toBe(null)
  253. expect('Failed setting prop "size" on <input>').not.toHaveBeenWarned()
  254. patchProp(el, 'size', null, 'foobar')
  255. expect('Failed setting prop "size" on <input>').toHaveBeenWarnedLast()
  256. })
  257. test('select with type (string property)', () => {
  258. const el = document.createElement('select')
  259. patchProp(el, 'type', null, 'test')
  260. expect(el.type).toBe('select-one')
  261. expect('Failed setting prop "type" on <select>').toHaveBeenWarnedLast()
  262. })
  263. test('select with willValidate (boolean property)', () => {
  264. const el = document.createElement('select')
  265. patchProp(el, 'willValidate', true, null)
  266. expect(el.willValidate).toBe(true)
  267. expect(
  268. 'Failed setting prop "willValidate" on <select>',
  269. ).toHaveBeenWarnedLast()
  270. })
  271. test('patch value for select', () => {
  272. const root = document.createElement('div')
  273. render(
  274. h('select', { value: 'foo' }, [
  275. h('option', { value: 'foo' }, 'foo'),
  276. h('option', { value: 'bar' }, 'bar'),
  277. ]),
  278. root,
  279. )
  280. const el = root.children[0] as HTMLSelectElement
  281. expect(el.value).toBe('foo')
  282. render(
  283. h('select', { value: 'baz' }, [
  284. h('option', { value: 'foo' }, 'foo'),
  285. h('option', { value: 'baz' }, 'baz'),
  286. ]),
  287. root,
  288. )
  289. expect(el.value).toBe('baz')
  290. })
  291. test('init empty value for option', () => {
  292. const root = document.createElement('div')
  293. render(
  294. h('select', { value: 'foo' }, [h('option', { value: '' }, 'foo')]),
  295. root,
  296. )
  297. const select = root.children[0] as HTMLSelectElement
  298. const option = select.children[0] as HTMLOptionElement
  299. expect(select.value).toBe('')
  300. expect(option.value).toBe('')
  301. })
  302. // #8780
  303. test('embedded tag with width and height', () => {
  304. // Width and height of some embedded element such as img、video、source、canvas
  305. // must be set as attribute
  306. const el = document.createElement('img')
  307. patchProp(el, 'width', null, '24px')
  308. expect(el.getAttribute('width')).toBe('24px')
  309. })
  310. // # 9762 should fallthrough to `key in el` logic for non embedded tags
  311. test('width and height on custom elements', () => {
  312. const el = document.createElement('foobar')
  313. patchProp(el, 'width', null, '24px')
  314. expect(el.getAttribute('width')).toBe('24px')
  315. })
  316. test('translate attribute', () => {
  317. const el = document.createElement('div')
  318. patchProp(el, 'translate', null, 'no')
  319. expect(el.translate).toBeFalsy()
  320. expect(el.getAttribute('translate')).toBe('no')
  321. })
  322. // #11647
  323. test('should not trigger input mutation when `value` is `undefined`', async () => {
  324. const fn = vi.fn()
  325. const comp = {
  326. setup() {
  327. const checked = ref()
  328. return () =>
  329. withDirectives(
  330. h('input', {
  331. type: 'checkbox',
  332. value: undefined,
  333. 'onUpdate:modelValue': (value: any) => {
  334. checked.value = value
  335. },
  336. }),
  337. [[vModelCheckbox, checked.value]],
  338. )
  339. },
  340. }
  341. const root = document.createElement('div')
  342. render(h(comp), root)
  343. document.body.append(root)
  344. const el = root.children[0] as HTMLInputElement
  345. const observer = new MutationObserver(fn)
  346. observer.observe(el, {
  347. attributes: true,
  348. })
  349. el.click()
  350. await nextTick()
  351. expect(fn).toBeCalledTimes(0)
  352. })
  353. })