rendererAttrsFallthrough.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. /**
  2. * @vitest-environment jsdom
  3. */
  4. // using DOM renderer because this case is mostly DOM-specific
  5. import { vi } from 'vitest'
  6. import {
  7. h,
  8. render,
  9. nextTick,
  10. mergeProps,
  11. ref,
  12. onUpdated,
  13. defineComponent,
  14. openBlock,
  15. createBlock,
  16. FunctionalComponent,
  17. createCommentVNode,
  18. Fragment,
  19. withModifiers
  20. } from '@vue/runtime-dom'
  21. import { PatchFlags } from '@vue/shared'
  22. describe('attribute fallthrough', () => {
  23. it('should allow attrs to fallthrough', async () => {
  24. const click = vi.fn()
  25. const childUpdated = vi.fn()
  26. const Hello = {
  27. setup() {
  28. const count = ref(0)
  29. function inc() {
  30. count.value++
  31. click()
  32. }
  33. return () =>
  34. h(Child, {
  35. foo: count.value + 1,
  36. id: 'test',
  37. class: 'c' + count.value,
  38. style: { color: count.value ? 'red' : 'green' },
  39. onClick: inc,
  40. 'data-id': count.value + 1
  41. })
  42. }
  43. }
  44. const Child = {
  45. setup(props: any) {
  46. onUpdated(childUpdated)
  47. return () =>
  48. h(
  49. 'div',
  50. {
  51. class: 'c2',
  52. style: { fontWeight: 'bold' }
  53. },
  54. props.foo
  55. )
  56. }
  57. }
  58. const root = document.createElement('div')
  59. document.body.appendChild(root)
  60. render(h(Hello), root)
  61. const node = root.children[0] as HTMLElement
  62. expect(node.getAttribute('id')).toBe('test')
  63. expect(node.getAttribute('foo')).toBe('1')
  64. expect(node.getAttribute('class')).toBe('c2 c0')
  65. expect(node.style.color).toBe('green')
  66. expect(node.style.fontWeight).toBe('bold')
  67. expect(node.dataset.id).toBe('1')
  68. node.dispatchEvent(new CustomEvent('click'))
  69. expect(click).toHaveBeenCalled()
  70. await nextTick()
  71. expect(childUpdated).toHaveBeenCalled()
  72. expect(node.getAttribute('id')).toBe('test')
  73. expect(node.getAttribute('foo')).toBe('2')
  74. expect(node.getAttribute('class')).toBe('c2 c1')
  75. expect(node.style.color).toBe('red')
  76. expect(node.style.fontWeight).toBe('bold')
  77. expect(node.dataset.id).toBe('2')
  78. })
  79. it('should only allow whitelisted fallthrough on functional component with optional props', async () => {
  80. const click = vi.fn()
  81. const childUpdated = vi.fn()
  82. const count = ref(0)
  83. function inc() {
  84. count.value++
  85. click()
  86. }
  87. const Hello = () =>
  88. h(Child, {
  89. foo: count.value + 1,
  90. id: 'test',
  91. class: 'c' + count.value,
  92. style: { color: count.value ? 'red' : 'green' },
  93. onClick: inc
  94. })
  95. const Child = (props: any) => {
  96. childUpdated()
  97. return h(
  98. 'div',
  99. {
  100. class: 'c2',
  101. style: { fontWeight: 'bold' }
  102. },
  103. props.foo
  104. )
  105. }
  106. const root = document.createElement('div')
  107. document.body.appendChild(root)
  108. render(h(Hello), root)
  109. const node = root.children[0] as HTMLElement
  110. // not whitelisted
  111. expect(node.getAttribute('id')).toBe(null)
  112. expect(node.getAttribute('foo')).toBe(null)
  113. // whitelisted: style, class, event listeners
  114. expect(node.getAttribute('class')).toBe('c2 c0')
  115. expect(node.style.color).toBe('green')
  116. expect(node.style.fontWeight).toBe('bold')
  117. node.dispatchEvent(new CustomEvent('click'))
  118. expect(click).toHaveBeenCalled()
  119. await nextTick()
  120. expect(childUpdated).toHaveBeenCalled()
  121. expect(node.getAttribute('id')).toBe(null)
  122. expect(node.getAttribute('foo')).toBe(null)
  123. expect(node.getAttribute('class')).toBe('c2 c1')
  124. expect(node.style.color).toBe('red')
  125. expect(node.style.fontWeight).toBe('bold')
  126. })
  127. it('should allow all attrs on functional component with declared props', async () => {
  128. const click = vi.fn()
  129. const childUpdated = vi.fn()
  130. const count = ref(0)
  131. function inc() {
  132. count.value++
  133. click()
  134. }
  135. const Hello = () =>
  136. h(Child, {
  137. foo: count.value + 1,
  138. id: 'test',
  139. class: 'c' + count.value,
  140. style: { color: count.value ? 'red' : 'green' },
  141. onClick: inc
  142. })
  143. const Child = (props: { foo: number }) => {
  144. childUpdated()
  145. return h(
  146. 'div',
  147. {
  148. class: 'c2',
  149. style: { fontWeight: 'bold' }
  150. },
  151. props.foo
  152. )
  153. }
  154. Child.props = ['foo']
  155. const root = document.createElement('div')
  156. document.body.appendChild(root)
  157. render(h(Hello), root)
  158. const node = root.children[0] as HTMLElement
  159. expect(node.getAttribute('id')).toBe('test')
  160. expect(node.getAttribute('foo')).toBe(null) // declared as prop
  161. expect(node.getAttribute('class')).toBe('c2 c0')
  162. expect(node.style.color).toBe('green')
  163. expect(node.style.fontWeight).toBe('bold')
  164. node.dispatchEvent(new CustomEvent('click'))
  165. expect(click).toHaveBeenCalled()
  166. await nextTick()
  167. expect(childUpdated).toHaveBeenCalled()
  168. expect(node.getAttribute('id')).toBe('test')
  169. expect(node.getAttribute('foo')).toBe(null)
  170. expect(node.getAttribute('class')).toBe('c2 c1')
  171. expect(node.style.color).toBe('red')
  172. expect(node.style.fontWeight).toBe('bold')
  173. })
  174. it('should fallthrough for nested components', async () => {
  175. const click = vi.fn()
  176. const childUpdated = vi.fn()
  177. const grandChildUpdated = vi.fn()
  178. const Hello = {
  179. setup() {
  180. const count = ref(0)
  181. function inc() {
  182. count.value++
  183. click()
  184. }
  185. return () =>
  186. h(Child, {
  187. foo: 1,
  188. id: 'test',
  189. class: 'c' + count.value,
  190. style: { color: count.value ? 'red' : 'green' },
  191. onClick: inc
  192. })
  193. }
  194. }
  195. const Child = {
  196. setup(props: any) {
  197. onUpdated(childUpdated)
  198. // HOC simply passing props down.
  199. // this will result in merging the same attrs, but should be deduped by
  200. // `mergeProps`.
  201. return () => h(GrandChild, props)
  202. }
  203. }
  204. const GrandChild = defineComponent({
  205. props: {
  206. id: String,
  207. foo: Number
  208. },
  209. setup(props) {
  210. onUpdated(grandChildUpdated)
  211. return () =>
  212. h(
  213. 'div',
  214. {
  215. id: props.id,
  216. class: 'c2',
  217. style: { fontWeight: 'bold' }
  218. },
  219. props.foo
  220. )
  221. }
  222. })
  223. const root = document.createElement('div')
  224. document.body.appendChild(root)
  225. render(h(Hello), root)
  226. const node = root.children[0] as HTMLElement
  227. // with declared props, any parent attr that isn't a prop falls through
  228. expect(node.getAttribute('id')).toBe('test')
  229. expect(node.getAttribute('class')).toBe('c2 c0')
  230. expect(node.style.color).toBe('green')
  231. expect(node.style.fontWeight).toBe('bold')
  232. node.dispatchEvent(new CustomEvent('click'))
  233. expect(click).toHaveBeenCalled()
  234. // ...while declared ones remain props
  235. expect(node.hasAttribute('foo')).toBe(false)
  236. await nextTick()
  237. expect(childUpdated).toHaveBeenCalled()
  238. expect(grandChildUpdated).toHaveBeenCalled()
  239. expect(node.getAttribute('id')).toBe('test')
  240. expect(node.getAttribute('class')).toBe('c2 c1')
  241. expect(node.style.color).toBe('red')
  242. expect(node.style.fontWeight).toBe('bold')
  243. expect(node.hasAttribute('foo')).toBe(false)
  244. })
  245. it('should not fallthrough with inheritAttrs: false', () => {
  246. const Parent = {
  247. render() {
  248. return h(Child, { foo: 1, class: 'parent' })
  249. }
  250. }
  251. const Child = defineComponent({
  252. props: ['foo'],
  253. inheritAttrs: false,
  254. render() {
  255. return h('div', this.foo)
  256. }
  257. })
  258. const root = document.createElement('div')
  259. document.body.appendChild(root)
  260. render(h(Parent), root)
  261. // should not contain class
  262. expect(root.innerHTML).toMatch(`<div>1</div>`)
  263. })
  264. // #3741
  265. it('should not fallthrough with inheritAttrs: false from mixins', () => {
  266. const Parent = {
  267. render() {
  268. return h(Child, { foo: 1, class: 'parent' })
  269. }
  270. }
  271. const mixin = {
  272. inheritAttrs: false
  273. }
  274. const Child = defineComponent({
  275. mixins: [mixin],
  276. props: ['foo'],
  277. render() {
  278. return h('div', this.foo)
  279. }
  280. })
  281. const root = document.createElement('div')
  282. document.body.appendChild(root)
  283. render(h(Parent), root)
  284. // should not contain class
  285. expect(root.innerHTML).toMatch(`<div>1</div>`)
  286. })
  287. it('explicit spreading with inheritAttrs: false', () => {
  288. const Parent = {
  289. render() {
  290. return h(Child, { foo: 1, class: 'parent' })
  291. }
  292. }
  293. const Child = defineComponent({
  294. props: ['foo'],
  295. inheritAttrs: false,
  296. render() {
  297. return h(
  298. 'div',
  299. mergeProps(
  300. {
  301. class: 'child'
  302. },
  303. this.$attrs
  304. ),
  305. this.foo
  306. )
  307. }
  308. })
  309. const root = document.createElement('div')
  310. document.body.appendChild(root)
  311. render(h(Parent), root)
  312. // should merge parent/child classes
  313. expect(root.innerHTML).toMatch(`<div class="child parent">1</div>`)
  314. })
  315. it('should warn when fallthrough fails on non-single-root', () => {
  316. const Parent = {
  317. render() {
  318. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  319. }
  320. }
  321. const Child = defineComponent({
  322. props: ['foo'],
  323. render() {
  324. return [h('div'), h('div')]
  325. }
  326. })
  327. const root = document.createElement('div')
  328. document.body.appendChild(root)
  329. render(h(Parent), root)
  330. expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
  331. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  332. })
  333. it('should dedupe same listeners when $attrs is used during render', () => {
  334. const click = vi.fn()
  335. const count = ref(0)
  336. function inc() {
  337. count.value++
  338. click()
  339. }
  340. const Parent = {
  341. render() {
  342. return h(Child, { onClick: inc })
  343. }
  344. }
  345. const Child = defineComponent({
  346. render() {
  347. return h(
  348. 'div',
  349. mergeProps(
  350. {
  351. onClick: withModifiers(() => {}, ['prevent', 'stop'])
  352. },
  353. this.$attrs
  354. )
  355. )
  356. }
  357. })
  358. const root = document.createElement('div')
  359. document.body.appendChild(root)
  360. render(h(Parent), root)
  361. const node = root.children[0] as HTMLElement
  362. node.dispatchEvent(new CustomEvent('click'))
  363. expect(click).toHaveBeenCalledTimes(1)
  364. expect(count.value).toBe(1)
  365. })
  366. it('should not warn when $attrs is used during render', () => {
  367. const Parent = {
  368. render() {
  369. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  370. }
  371. }
  372. const Child = defineComponent({
  373. props: ['foo'],
  374. render() {
  375. return [h('div'), h('div', this.$attrs)]
  376. }
  377. })
  378. const root = document.createElement('div')
  379. document.body.appendChild(root)
  380. render(h(Parent), root)
  381. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  382. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  383. expect(root.innerHTML).toBe(`<div></div><div class="parent"></div>`)
  384. })
  385. it('should not warn when context.attrs is used during render', () => {
  386. const Parent = {
  387. render() {
  388. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  389. }
  390. }
  391. const Child = defineComponent({
  392. props: ['foo'],
  393. setup(_props, { attrs }) {
  394. return () => [h('div'), h('div', attrs)]
  395. }
  396. })
  397. const root = document.createElement('div')
  398. document.body.appendChild(root)
  399. render(h(Parent), root)
  400. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  401. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  402. expect(root.innerHTML).toBe(`<div></div><div class="parent"></div>`)
  403. })
  404. it('should not warn when context.attrs is used during render (functional)', () => {
  405. const Parent = {
  406. render() {
  407. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  408. }
  409. }
  410. const Child: FunctionalComponent = (_, { attrs }) => [
  411. h('div'),
  412. h('div', attrs)
  413. ]
  414. Child.props = ['foo']
  415. const root = document.createElement('div')
  416. document.body.appendChild(root)
  417. render(h(Parent), root)
  418. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  419. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  420. expect(root.innerHTML).toBe(`<div></div><div class="parent"></div>`)
  421. })
  422. it('should not warn when functional component has optional props', () => {
  423. const Parent = {
  424. render() {
  425. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  426. }
  427. }
  428. const Child = (props: any) => [h('div'), h('div', { class: props.class })]
  429. const root = document.createElement('div')
  430. document.body.appendChild(root)
  431. render(h(Parent), root)
  432. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  433. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  434. expect(root.innerHTML).toBe(`<div></div><div class="parent"></div>`)
  435. })
  436. it('should warn when functional component has props and does not use attrs', () => {
  437. const Parent = {
  438. render() {
  439. return h(Child, { foo: 1, class: 'parent', onBar: () => {} })
  440. }
  441. }
  442. const Child: FunctionalComponent = () => [h('div'), h('div')]
  443. Child.props = ['foo']
  444. const root = document.createElement('div')
  445. document.body.appendChild(root)
  446. render(h(Parent), root)
  447. expect(`Extraneous non-props attributes`).toHaveBeenWarned()
  448. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  449. expect(root.innerHTML).toBe(`<div></div><div></div>`)
  450. })
  451. // #677
  452. it('should update merged dynamic attrs on optimized child root', async () => {
  453. const aria = ref('true')
  454. const cls = ref('bar')
  455. const Parent = {
  456. render() {
  457. return h(Child, { 'aria-hidden': aria.value, class: cls.value })
  458. }
  459. }
  460. const Child = {
  461. props: [],
  462. render() {
  463. return openBlock(), createBlock('div')
  464. }
  465. }
  466. const root = document.createElement('div')
  467. document.body.appendChild(root)
  468. render(h(Parent), root)
  469. expect(root.innerHTML).toBe(`<div aria-hidden="true" class="bar"></div>`)
  470. aria.value = 'false'
  471. await nextTick()
  472. expect(root.innerHTML).toBe(`<div aria-hidden="false" class="bar"></div>`)
  473. cls.value = 'barr'
  474. await nextTick()
  475. expect(root.innerHTML).toBe(`<div aria-hidden="false" class="barr"></div>`)
  476. })
  477. it('should not let listener fallthrough when declared in emits (stateful)', () => {
  478. const Child = defineComponent({
  479. emits: ['click'],
  480. render() {
  481. return h(
  482. 'button',
  483. {
  484. onClick: () => {
  485. this.$emit('click', 'custom')
  486. }
  487. },
  488. 'hello'
  489. )
  490. }
  491. })
  492. const onClick = vi.fn()
  493. const App = {
  494. render() {
  495. return h(Child, {
  496. onClick
  497. })
  498. }
  499. }
  500. const root = document.createElement('div')
  501. document.body.appendChild(root)
  502. render(h(App), root)
  503. const node = root.children[0] as HTMLElement
  504. node.dispatchEvent(new CustomEvent('click'))
  505. expect(onClick).toHaveBeenCalledTimes(1)
  506. expect(onClick).toHaveBeenCalledWith('custom')
  507. })
  508. it('should not let listener fallthrough when declared in emits (functional)', () => {
  509. const Child: FunctionalComponent<{}, { click: any }> = (_, { emit }) => {
  510. // should not be in props
  511. expect((_ as any).onClick).toBeUndefined()
  512. return h('button', {
  513. onClick: () => {
  514. emit('click', 'custom')
  515. }
  516. })
  517. }
  518. Child.emits = ['click']
  519. const onClick = vi.fn()
  520. const App = {
  521. render() {
  522. return h(Child, {
  523. onClick
  524. })
  525. }
  526. }
  527. const root = document.createElement('div')
  528. document.body.appendChild(root)
  529. render(h(App), root)
  530. const node = root.children[0] as HTMLElement
  531. node.dispatchEvent(new CustomEvent('click'))
  532. expect(onClick).toHaveBeenCalledTimes(1)
  533. expect(onClick).toHaveBeenCalledWith('custom')
  534. })
  535. it('should support fallthrough for fragments with single element + comments', () => {
  536. const click = vi.fn()
  537. const Hello = {
  538. setup() {
  539. return () => h(Child, { class: 'foo', onClick: click })
  540. }
  541. }
  542. const Child = {
  543. setup() {
  544. return () => (
  545. openBlock(),
  546. createBlock(
  547. Fragment,
  548. null,
  549. [
  550. createCommentVNode('hello'),
  551. h('button'),
  552. createCommentVNode('world')
  553. ],
  554. PatchFlags.STABLE_FRAGMENT | PatchFlags.DEV_ROOT_FRAGMENT
  555. )
  556. )
  557. }
  558. }
  559. const root = document.createElement('div')
  560. document.body.appendChild(root)
  561. render(h(Hello), root)
  562. expect(root.innerHTML).toBe(
  563. `<!--hello--><button class="foo"></button><!--world-->`
  564. )
  565. const button = root.children[0] as HTMLElement
  566. button.dispatchEvent(new CustomEvent('click'))
  567. expect(click).toHaveBeenCalled()
  568. })
  569. // #1989
  570. it('should not fallthrough v-model listeners with corresponding declared prop', () => {
  571. let textFoo = ''
  572. let textBar = ''
  573. const click = vi.fn()
  574. const App = defineComponent({
  575. setup() {
  576. return () =>
  577. h(Child, {
  578. modelValue: textFoo,
  579. 'onUpdate:modelValue': (val: string) => {
  580. textFoo = val
  581. }
  582. })
  583. }
  584. })
  585. const Child = defineComponent({
  586. props: ['modelValue'],
  587. setup(_props, { emit }) {
  588. return () =>
  589. h(GrandChild, {
  590. modelValue: textBar,
  591. 'onUpdate:modelValue': (val: string) => {
  592. textBar = val
  593. emit('update:modelValue', 'from Child')
  594. }
  595. })
  596. }
  597. })
  598. const GrandChild = defineComponent({
  599. props: ['modelValue'],
  600. setup(_props, { emit }) {
  601. return () =>
  602. h('button', {
  603. onClick() {
  604. click()
  605. emit('update:modelValue', 'from GrandChild')
  606. }
  607. })
  608. }
  609. })
  610. const root = document.createElement('div')
  611. document.body.appendChild(root)
  612. render(h(App), root)
  613. const node = root.children[0] as HTMLElement
  614. node.dispatchEvent(new CustomEvent('click'))
  615. expect(click).toHaveBeenCalled()
  616. expect(textBar).toBe('from GrandChild')
  617. expect(textFoo).toBe('from Child')
  618. })
  619. })