componentAttrs.spec.ts 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202
  1. import {
  2. type Ref,
  3. nextTick,
  4. onUpdated,
  5. ref,
  6. withModifiers,
  7. } from '@vue/runtime-dom'
  8. import {
  9. VaporTeleport,
  10. createComponent,
  11. createDynamicComponent,
  12. createIf,
  13. createSlot,
  14. defineVaporComponent,
  15. delegateEvents,
  16. renderEffect,
  17. setClass,
  18. setDynamicProps,
  19. setInsertionState,
  20. setProp,
  21. setStyle,
  22. template,
  23. } from '../src'
  24. import { makeRender } from './_utils'
  25. import { stringifyStyle } from '@vue/shared'
  26. import { setElementText } from '../src/dom/prop'
  27. const define = makeRender<any>()
  28. delegateEvents('click')
  29. describe('attribute fallthrough', () => {
  30. it('should allow attrs to fallthrough', async () => {
  31. const t0 = template('<div>', true)
  32. const { component: Child } = define({
  33. props: ['foo'],
  34. setup(props: any) {
  35. const n0 = t0() as Element
  36. renderEffect(() => setElementText(n0, props.foo))
  37. return n0
  38. },
  39. })
  40. const foo = ref(1)
  41. const id = ref('a')
  42. const { host } = define({
  43. setup() {
  44. return createComponent(
  45. Child,
  46. {
  47. foo: () => foo.value,
  48. id: () => id.value,
  49. },
  50. null,
  51. true,
  52. )
  53. },
  54. }).render()
  55. expect(host.innerHTML).toBe('<div id="a">1</div>')
  56. foo.value++
  57. await nextTick()
  58. expect(host.innerHTML).toBe('<div id="a">2</div>')
  59. id.value = 'b'
  60. await nextTick()
  61. expect(host.innerHTML).toBe('<div id="b">2</div>')
  62. })
  63. it('should only allow whitelisted fallthrough on functional component with optional props', async () => {
  64. const click = vi.fn()
  65. const childUpdated = vi.fn()
  66. const count = ref(0)
  67. function inc() {
  68. count.value++
  69. click()
  70. }
  71. const Hello = () =>
  72. createComponent(Child, {
  73. foo: () => count.value + 1,
  74. id: () => 'test',
  75. class: () => 'c' + count.value,
  76. style: () => ({
  77. color: count.value ? 'red' : 'green',
  78. }),
  79. onClick: () => inc,
  80. })
  81. const { component: Child } = define((props: any) => {
  82. childUpdated()
  83. const n0 = template(
  84. '<div class="c2" style="font-weight: bold"></div>',
  85. true,
  86. )() as Element
  87. renderEffect(() => setElementText(n0, props.foo))
  88. return n0
  89. })
  90. const { host } = define(Hello).render()
  91. expect(host.innerHTML).toBe(
  92. '<div class="c2 c0" style="font-weight: bold; color: green;">1</div>',
  93. )
  94. const node = host.children[0] as HTMLElement
  95. // not whitelisted
  96. expect(node.getAttribute('id')).toBe(null)
  97. expect(node.getAttribute('foo')).toBe(null)
  98. // whitelisted: style, class, event listeners
  99. expect(node.getAttribute('class')).toBe('c2 c0')
  100. expect(node.style.color).toBe('green')
  101. expect(node.style.fontWeight).toBe('bold')
  102. node.dispatchEvent(new CustomEvent('click'))
  103. expect(click).toHaveBeenCalled()
  104. await nextTick()
  105. expect(childUpdated).toHaveBeenCalled()
  106. expect(node.getAttribute('id')).toBe(null)
  107. expect(node.getAttribute('foo')).toBe(null)
  108. expect(node.getAttribute('class')).toBe('c2 c1')
  109. expect(node.style.color).toBe('red')
  110. expect(node.style.fontWeight).toBe('bold')
  111. })
  112. it('should allow all attrs on functional component with declared props', async () => {
  113. const click = vi.fn()
  114. const childUpdated = vi.fn()
  115. const count = ref(0)
  116. function inc() {
  117. count.value++
  118. click()
  119. }
  120. const Hello = () =>
  121. createComponent(Child, {
  122. foo: () => count.value + 1,
  123. id: () => 'test',
  124. class: () => 'c' + count.value,
  125. style: () => ({ color: count.value ? 'red' : 'green' }),
  126. onClick: () => inc,
  127. })
  128. const Child = defineVaporComponent((props: any) => {
  129. childUpdated()
  130. const n0 = template(
  131. '<div class="c2" style="font-weight: bold"></div>',
  132. true,
  133. )() as Element
  134. renderEffect(() => setElementText(n0, props.foo))
  135. return n0
  136. })
  137. Child.props = ['foo']
  138. const { host } = define(Hello).render()
  139. const node = host.children[0] as HTMLElement
  140. expect(node.getAttribute('id')).toBe('test')
  141. expect(node.getAttribute('foo')).toBe(null) // declared as prop
  142. expect(node.getAttribute('class')).toBe('c2 c0')
  143. expect(node.style.color).toBe('green')
  144. expect(node.style.fontWeight).toBe('bold')
  145. node.dispatchEvent(new CustomEvent('click'))
  146. expect(click).toHaveBeenCalled()
  147. await nextTick()
  148. expect(childUpdated).toHaveBeenCalled()
  149. expect(node.getAttribute('id')).toBe('test')
  150. expect(node.getAttribute('foo')).toBe(null)
  151. expect(node.getAttribute('class')).toBe('c2 c1')
  152. expect(node.style.color).toBe('red')
  153. expect(node.style.fontWeight).toBe('bold')
  154. })
  155. it('should fallthrough for nested components', async () => {
  156. const click = vi.fn()
  157. const childUpdated = vi.fn()
  158. const grandChildUpdated = vi.fn()
  159. const Hello = {
  160. setup() {
  161. const count = ref(0)
  162. function inc() {
  163. count.value++
  164. click()
  165. }
  166. return createComponent(Child, {
  167. foo: () => count.value + 1,
  168. id: () => 'test',
  169. class: () => 'c' + count.value,
  170. style: () => ({
  171. color: count.value ? 'red' : 'green',
  172. }),
  173. onClick: () => inc,
  174. })
  175. },
  176. }
  177. const Child = defineVaporComponent({
  178. setup(props: any) {
  179. onUpdated(childUpdated)
  180. // HOC simply passing props down.
  181. // this will result in merging the same attrs, but should be deduped by
  182. // `mergeProps`.
  183. return createComponent(GrandChild, props, null, true)
  184. },
  185. })
  186. const GrandChild = defineVaporComponent({
  187. props: {
  188. id: String,
  189. foo: Number,
  190. },
  191. setup(props) {
  192. onUpdated(grandChildUpdated)
  193. const n0 = template(
  194. '<div class="c2" style="font-weight: bold"></div>',
  195. true,
  196. )() as Element
  197. renderEffect(() => {
  198. setProp(n0, 'id', props.id)
  199. setElementText(n0, props.foo)
  200. })
  201. return n0
  202. },
  203. })
  204. const { host } = define(Hello).render()
  205. expect(host.innerHTML).toBe(
  206. '<div class="c2 c0" style="font-weight: bold; color: green;" id="test">1</div>',
  207. )
  208. const node = host.children[0] as HTMLElement
  209. // with declared props, any parent attr that isn't a prop falls through
  210. expect(node.getAttribute('id')).toBe('test')
  211. expect(node.getAttribute('class')).toBe('c2 c0')
  212. expect(node.style.color).toBe('green')
  213. expect(node.style.fontWeight).toBe('bold')
  214. node.dispatchEvent(new CustomEvent('click'))
  215. expect(click).toHaveBeenCalled()
  216. // ...while declared ones remain props
  217. expect(node.hasAttribute('foo')).toBe(false)
  218. await nextTick()
  219. // child should not update, due to it not accessing props
  220. // this is a optimization in vapor mode
  221. expect(childUpdated).not.toHaveBeenCalled()
  222. expect(grandChildUpdated).toHaveBeenCalled()
  223. expect(node.getAttribute('id')).toBe('test')
  224. expect(node.getAttribute('class')).toBe('c2 c1')
  225. expect(node.style.color).toBe('red')
  226. expect(node.style.fontWeight).toBe('bold')
  227. expect(node.hasAttribute('foo')).toBe(false)
  228. })
  229. it('should not fallthrough with inheritAttrs: false', () => {
  230. const Parent = defineVaporComponent({
  231. setup() {
  232. return createComponent(Child, { foo: () => 1, class: () => 'parent' })
  233. },
  234. })
  235. const Child = defineVaporComponent({
  236. props: ['foo'],
  237. inheritAttrs: false,
  238. setup(props) {
  239. const n0 = template('<div></div>', true)() as Element
  240. renderEffect(() => setElementText(n0, props.foo))
  241. return n0
  242. },
  243. })
  244. const { html } = define(Parent).render()
  245. // should not contain class
  246. expect(html()).toMatch(`<div>1</div>`)
  247. })
  248. it('explicit spreading with inheritAttrs: false', () => {
  249. const Parent = defineVaporComponent({
  250. setup() {
  251. return createComponent(Child, { foo: () => 1, class: () => 'parent' })
  252. },
  253. })
  254. const Child = defineVaporComponent({
  255. props: ['foo'],
  256. inheritAttrs: false,
  257. setup(props, { attrs }) {
  258. const n0 = template('<div>', true)() as Element
  259. renderEffect(() => {
  260. setElementText(n0, props.foo)
  261. setDynamicProps(n0, [{ class: 'child' }, attrs])
  262. })
  263. return n0
  264. },
  265. })
  266. const { html } = define(Parent).render()
  267. // should merge parent/child classes
  268. expect(html()).toMatch(`<div class="child parent">1</div>`)
  269. })
  270. it('should warn when fallthrough fails on non-single-root', () => {
  271. const Parent = {
  272. setup() {
  273. return createComponent(Child, {
  274. foo: () => 1,
  275. class: () => 'parent',
  276. onBar: () => () => {},
  277. })
  278. },
  279. }
  280. const Child = defineVaporComponent({
  281. props: ['foo'],
  282. render() {
  283. return [template('<div></div>')(), template('<div></div>')()]
  284. },
  285. })
  286. define(Parent).render()
  287. expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
  288. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  289. })
  290. it('should warn when fallthrough fails on teleport root node', () => {
  291. const Parent = {
  292. render() {
  293. return createComponent(Child, { class: () => 'parent' })
  294. },
  295. }
  296. const target = document.createElement('div')
  297. const Child = defineVaporComponent({
  298. render() {
  299. return createComponent(
  300. VaporTeleport,
  301. { to: () => target },
  302. {
  303. default: () => template('<div></div>')(),
  304. },
  305. )
  306. },
  307. })
  308. document.body.appendChild(target)
  309. define(Parent).render()
  310. expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
  311. })
  312. it('should dedupe same listeners when $attrs is used during render', () => {
  313. const click = vi.fn()
  314. const count = ref(0)
  315. function inc() {
  316. count.value++
  317. click()
  318. }
  319. const Parent = {
  320. render() {
  321. return createComponent(Child, { onClick: () => inc })
  322. },
  323. }
  324. const Child = defineVaporComponent({
  325. setup(_, { attrs }) {
  326. const n0 = template('<div></div>', true)() as any
  327. n0.$evtclick = withModifiers(() => {}, ['prevent', 'stop'])
  328. renderEffect(() => setDynamicProps(n0, [attrs]))
  329. return n0
  330. },
  331. })
  332. const { host } = define(Parent).render()
  333. const node = host.children[0] as HTMLElement
  334. node.dispatchEvent(new CustomEvent('click'))
  335. expect(click).toHaveBeenCalledTimes(1)
  336. expect(count.value).toBe(1)
  337. })
  338. it('should not warn when context.attrs is used during render', () => {
  339. const Parent = {
  340. render() {
  341. return createComponent(Child, {
  342. foo: () => 1,
  343. class: () => 'parent',
  344. onBar: () => () => {},
  345. })
  346. },
  347. }
  348. const Child = defineVaporComponent({
  349. props: ['foo'],
  350. render(_ctx, $props, $emit, $attrs, $slots) {
  351. const n0 = template('<div></div>')() as Element
  352. const n1 = template('<div></div>')() as Element
  353. renderEffect(() => {
  354. setDynamicProps(n1, [$attrs])
  355. })
  356. return [n0, n1]
  357. },
  358. })
  359. const { html } = define(Parent).render()
  360. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  361. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  362. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  363. })
  364. it('should not warn when context.attrs is used during render (functional)', () => {
  365. const Parent = {
  366. render() {
  367. return createComponent(Child, {
  368. foo: () => 1,
  369. class: () => 'parent',
  370. onBar: () => () => {},
  371. })
  372. },
  373. }
  374. const { component: Child } = define((_: any, { attrs }: any) => {
  375. const n0 = template('<div></div>')() as Element
  376. const n1 = template('<div></div>')() as Element
  377. renderEffect(() => {
  378. setDynamicProps(n1, [attrs])
  379. })
  380. return [n0, n1]
  381. })
  382. Child.props = ['foo']
  383. const { html } = define(Parent).render()
  384. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  385. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  386. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  387. })
  388. it('should not warn when functional component has optional props', () => {
  389. const Parent = {
  390. render() {
  391. return createComponent(Child, {
  392. foo: () => 1,
  393. class: () => 'parent',
  394. onBar: () => () => {},
  395. })
  396. },
  397. }
  398. const { component: Child } = define((props: any) => {
  399. const n0 = template('<div></div>')() as Element
  400. const n1 = template('<div></div>')() as Element
  401. renderEffect(() => {
  402. setClass(n1, props.class)
  403. })
  404. return [n0, n1]
  405. })
  406. const { html } = define(Parent).render()
  407. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  408. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  409. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  410. })
  411. it('should warn when functional component has props and does not use attrs', () => {
  412. const Parent = {
  413. render() {
  414. return createComponent(Child, {
  415. foo: () => 1,
  416. class: () => 'parent',
  417. onBar: () => () => {},
  418. })
  419. },
  420. }
  421. const { component: Child } = define(() => [
  422. template('<div></div>')(),
  423. template('<div></div>')(),
  424. ])
  425. Child.props = ['foo']
  426. const { html } = define(Parent).render()
  427. expect(`Extraneous non-props attributes`).toHaveBeenWarned()
  428. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  429. expect(html()).toBe(`<div></div><div></div>`)
  430. })
  431. it('should not let listener fallthrough when declared in emits (stateful)', () => {
  432. const Child = defineVaporComponent({
  433. emits: ['click'],
  434. render(_ctx, $props, $emit, $attrs, $slots) {
  435. const n0 = template('<button>hello</button>')() as any
  436. n0.$evtclick = () => {
  437. // @ts-expect-error
  438. $emit('click', 'custom')
  439. }
  440. return n0
  441. },
  442. })
  443. const onClick = vi.fn()
  444. const App = defineVaporComponent({
  445. render() {
  446. return createComponent(
  447. Child,
  448. {
  449. onClick: () => onClick,
  450. },
  451. null,
  452. true,
  453. )
  454. },
  455. })
  456. const { host } = define(App).render()
  457. const node = host.children[0] as HTMLElement
  458. node.click()
  459. expect(onClick).toHaveBeenCalledTimes(1)
  460. expect(onClick).toHaveBeenCalledWith('custom')
  461. })
  462. it('should not let listener fallthrough when declared in emits (functional)', () => {
  463. const { component: Child } = define((_: any, { emit }: any) => {
  464. // should not be in props
  465. expect((_ as any).onClick).toBeUndefined()
  466. const n0 = template('<button></button>')() as any
  467. n0.$evtclick = () => {
  468. emit('click', 'custom')
  469. }
  470. return n0
  471. })
  472. Child.emits = ['click']
  473. const onClick = vi.fn()
  474. const App = defineVaporComponent({
  475. render() {
  476. return createComponent(Child, {
  477. onClick: () => onClick,
  478. })
  479. },
  480. })
  481. const { host } = define(App).render()
  482. const node = host.children[0] as HTMLElement
  483. node.click()
  484. expect(onClick).toHaveBeenCalledTimes(1)
  485. expect(onClick).toHaveBeenCalledWith('custom')
  486. })
  487. it('should support fallthrough for single element + comments', () => {
  488. const click = vi.fn()
  489. const Hello = defineVaporComponent({
  490. render() {
  491. return createComponent(Child, {
  492. class: () => 'foo',
  493. onClick: () => click,
  494. })
  495. },
  496. })
  497. const Child = defineVaporComponent({
  498. render() {
  499. return [
  500. template('<!--hello-->')(),
  501. template('<button></button>')(),
  502. template('<!--world-->')(),
  503. ]
  504. },
  505. })
  506. const { host } = define(Hello).render()
  507. expect(host.innerHTML).toBe(
  508. `<!--hello--><button class="foo"></button><!--world-->`,
  509. )
  510. const button = host.children[0] as HTMLElement
  511. button.dispatchEvent(new CustomEvent('click'))
  512. expect(click).toHaveBeenCalled()
  513. })
  514. it('should support fallthrough for nested element + comments', async () => {
  515. const toggle = ref(false)
  516. const Child = defineVaporComponent({
  517. setup() {
  518. const n0 = template('<!-- comment A -->')() as any
  519. const n1 = createIf(
  520. () => toggle.value,
  521. () => template('<span>Foo</span>')(),
  522. () => {
  523. const n2 = template('<!-- comment B -->')() as any
  524. const n3 = template('<div>Bar</div>')() as any
  525. return [n2, n3]
  526. },
  527. )
  528. return [n0, n1]
  529. },
  530. })
  531. const Root = defineVaporComponent({
  532. setup() {
  533. return createComponent(Child, { class: () => 'red' })
  534. },
  535. })
  536. const { host } = define(Root).render()
  537. expect(host.innerHTML).toBe(
  538. `<!-- comment A --><!-- comment B --><div class="red">Bar</div><!--if-->`,
  539. )
  540. toggle.value = true
  541. await nextTick()
  542. expect(host.innerHTML).toBe(
  543. `<!-- comment A --><span class=\"red\">Foo</span><!--if-->`,
  544. )
  545. })
  546. it('should not fallthrough v-model listeners with corresponding declared prop', () => {
  547. let textFoo = ''
  548. let textBar = ''
  549. const click = vi.fn()
  550. const App = defineVaporComponent({
  551. render() {
  552. return createComponent(Child, {
  553. modelValue: () => textFoo,
  554. 'onUpdate:modelValue': () => (val: string) => {
  555. textFoo = val
  556. },
  557. })
  558. },
  559. })
  560. const Child = defineVaporComponent({
  561. props: ['modelValue'],
  562. setup(_props, { emit }) {
  563. return createComponent(GrandChild, {
  564. modelValue: () => textBar,
  565. 'onUpdate:modelValue': () => (val: string) => {
  566. textBar = val
  567. emit('update:modelValue', 'from Child')
  568. },
  569. })
  570. },
  571. })
  572. const GrandChild = defineVaporComponent({
  573. props: ['modelValue'],
  574. setup(_props, { emit }) {
  575. const n0 = template('<button></button>')() as any
  576. n0.$evtclick = () => {
  577. click()
  578. emit('update:modelValue', 'from GrandChild')
  579. }
  580. return n0
  581. },
  582. })
  583. const { host } = define(App).render()
  584. const node = host.children[0] as HTMLElement
  585. node.click()
  586. expect(click).toHaveBeenCalled()
  587. expect(textBar).toBe('from GrandChild')
  588. expect(textFoo).toBe('from Child')
  589. })
  590. it('should track this.$attrs access in slots', async () => {
  591. const GrandChild = defineVaporComponent({
  592. render() {
  593. return createSlot('default')
  594. },
  595. })
  596. const Child = defineVaporComponent({
  597. // @ts-expect-error
  598. components: { GrandChild },
  599. render(_ctx, $props, $emit, $attrs, $slots) {
  600. const n0 = template('<div></div>')() as any
  601. setInsertionState(n0)
  602. createComponent(GrandChild, null, {
  603. default: () => {
  604. const n1 = template(' ')()
  605. renderEffect(() => setElementText(n1, $attrs.foo))
  606. return n1
  607. },
  608. })
  609. return n0
  610. },
  611. })
  612. const obj = ref(1)
  613. const App = defineVaporComponent({
  614. render() {
  615. return createComponent(Child, { foo: () => obj.value })
  616. },
  617. })
  618. const { html } = define(App).render()
  619. expect(html()).toBe('<div foo="1">1<!--slot--></div>')
  620. obj.value = 2
  621. await nextTick()
  622. expect(html()).toBe('<div foo="2">2<!--slot--></div>')
  623. })
  624. it('should allow attrs to fallthrough on component with comment at root', async () => {
  625. const t0 = template('<!--comment-->')
  626. const t1 = template('<div>')
  627. const { component: Child } = define({
  628. props: ['foo'],
  629. setup(props: any) {
  630. const n0 = t0()
  631. const n1 = t1()
  632. renderEffect(() => setElementText(n1, props.foo))
  633. return [n0, n1]
  634. },
  635. })
  636. const foo = ref(1)
  637. const id = ref('a')
  638. const { host } = define({
  639. setup() {
  640. return createComponent(
  641. Child,
  642. {
  643. foo: () => foo.value,
  644. id: () => id.value,
  645. },
  646. null,
  647. true,
  648. )
  649. },
  650. }).render()
  651. expect(host.innerHTML).toBe('<!--comment--><div id="a">1</div>')
  652. foo.value++
  653. await nextTick()
  654. expect(host.innerHTML).toBe('<!--comment--><div id="a">2</div>')
  655. id.value = 'b'
  656. await nextTick()
  657. expect(host.innerHTML).toBe('<!--comment--><div id="b">2</div>')
  658. })
  659. it('if block', async () => {
  660. const t0 = template('<div>foo</div>', true)
  661. const t1 = template('<div>bar</div>', true)
  662. const t2 = template('<div>baz</div>', true)
  663. const { component: Child } = define({
  664. setup() {
  665. const n0 = createIf(
  666. () => true,
  667. () => {
  668. const n2 = t0()
  669. return n2
  670. },
  671. () =>
  672. createIf(
  673. () => false,
  674. () => {
  675. const n4 = t1()
  676. return n4
  677. },
  678. () => {
  679. const n7 = t2()
  680. return n7
  681. },
  682. ),
  683. )
  684. return n0
  685. },
  686. })
  687. const id = ref('a')
  688. const { host } = define({
  689. setup() {
  690. return createComponent(
  691. Child,
  692. {
  693. id: () => id.value,
  694. },
  695. null,
  696. true,
  697. )
  698. },
  699. }).render()
  700. expect(host.innerHTML).toBe('<div id="a">foo</div><!--if-->')
  701. })
  702. it('should not allow attrs to fallthrough on component with multiple roots', async () => {
  703. const t0 = template('<span>')
  704. const t1 = template('<div>')
  705. const { component: Child } = define({
  706. props: ['foo'],
  707. setup(props: any) {
  708. const n0 = t0()
  709. const n1 = t1()
  710. renderEffect(() => setElementText(n1, props.foo))
  711. return [n0, n1]
  712. },
  713. })
  714. const foo = ref(1)
  715. const id = ref('a')
  716. const { host } = define({
  717. setup() {
  718. return createComponent(
  719. Child,
  720. {
  721. foo: () => foo.value,
  722. id: () => id.value,
  723. },
  724. null,
  725. true,
  726. )
  727. },
  728. }).render()
  729. expect(host.innerHTML).toBe('<span></span><div>1</div>')
  730. expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
  731. })
  732. it('should not allow attrs to fallthrough on component with single comment root', async () => {
  733. const t0 = template('<!--comment-->')
  734. const { component: Child } = define({
  735. setup() {
  736. const n0 = t0()
  737. return [n0]
  738. },
  739. })
  740. const id = ref('a')
  741. const { host } = define({
  742. setup() {
  743. return createComponent(Child, { id: () => id.value }, null, true)
  744. },
  745. }).render()
  746. expect(host.innerHTML).toBe('<!--comment-->')
  747. expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
  748. })
  749. it('should not fallthrough if explicitly pass inheritAttrs: false', async () => {
  750. const t0 = template('<div>', true)
  751. const { component: Child } = define({
  752. props: ['foo'],
  753. inheritAttrs: false,
  754. setup(props: any) {
  755. const n0 = t0() as Element
  756. renderEffect(() => setElementText(n0, props.foo))
  757. return n0
  758. },
  759. })
  760. const foo = ref(1)
  761. const id = ref('a')
  762. const { host } = define({
  763. setup() {
  764. return createComponent(
  765. Child,
  766. {
  767. foo: () => foo.value,
  768. id: () => id.value,
  769. },
  770. null,
  771. true,
  772. )
  773. },
  774. }).render()
  775. expect(host.innerHTML).toBe('<div>1</div>')
  776. foo.value++
  777. await nextTick()
  778. expect(host.innerHTML).toBe('<div>2</div>')
  779. id.value = 'b'
  780. await nextTick()
  781. expect(host.innerHTML).toBe('<div>2</div>')
  782. })
  783. it('should pass through attrs in nested single root components', async () => {
  784. const t0 = template('<div>', true)
  785. const { component: Grandson } = define({
  786. props: ['custom-attr'],
  787. setup(_: any, { attrs }: any) {
  788. const n0 = t0() as Element
  789. renderEffect(() => setElementText(n0, attrs.foo))
  790. return n0
  791. },
  792. })
  793. const { component: Child } = define({
  794. setup() {
  795. const n0 = createComponent(
  796. Grandson,
  797. {
  798. 'custom-attr': () => 'custom-attr',
  799. },
  800. null,
  801. true,
  802. )
  803. return n0
  804. },
  805. })
  806. const foo = ref(1)
  807. const id = ref('a')
  808. const { host } = define({
  809. setup() {
  810. return createComponent(
  811. Child,
  812. {
  813. foo: () => foo.value,
  814. id: () => id.value,
  815. },
  816. null,
  817. true,
  818. )
  819. },
  820. }).render()
  821. expect(host.innerHTML).toBe('<div foo="1" id="a">1</div>')
  822. foo.value++
  823. await nextTick()
  824. expect(host.innerHTML).toBe('<div foo="2" id="a">2</div>')
  825. id.value = 'b'
  826. await nextTick()
  827. expect(host.innerHTML).toBe('<div foo="2" id="b">2</div>')
  828. })
  829. it('should merge classes', async () => {
  830. const rootClass = ref('root')
  831. const parentClass = ref('parent')
  832. const childClass = ref('child')
  833. const t0 = template('<div>', true /* root */)
  834. const Child = defineVaporComponent({
  835. setup() {
  836. const n = t0() as Element
  837. renderEffect(() => {
  838. // binding on template root generates incremental class setter
  839. setClass(n, childClass.value)
  840. })
  841. return n
  842. },
  843. })
  844. const Parent = defineVaporComponent({
  845. setup() {
  846. return createComponent(
  847. Child,
  848. {
  849. class: () => parentClass.value,
  850. },
  851. null,
  852. true, // pass single root flag
  853. )
  854. },
  855. })
  856. const { host } = define({
  857. setup() {
  858. return createComponent(Parent, {
  859. class: () => rootClass.value,
  860. })
  861. },
  862. }).render()
  863. const list = host.children[0].classList
  864. // assert classes without being order-sensitive
  865. function assertClasses(cls: string[]) {
  866. expect(list.length).toBe(cls.length)
  867. for (const c of cls) {
  868. expect(list.contains(c)).toBe(true)
  869. }
  870. }
  871. assertClasses(['root', 'parent', 'child'])
  872. rootClass.value = 'root1'
  873. await nextTick()
  874. assertClasses(['root1', 'parent', 'child'])
  875. parentClass.value = 'parent1'
  876. await nextTick()
  877. assertClasses(['root1', 'parent1', 'child'])
  878. childClass.value = 'child1'
  879. await nextTick()
  880. assertClasses(['root1', 'parent1', 'child1'])
  881. })
  882. it('should merge styles', async () => {
  883. const rootStyle: Ref<string | Record<string, string>> = ref('color:red')
  884. const parentStyle: Ref<string | null> = ref('font-size:12px')
  885. const childStyle = ref('font-weight:bold')
  886. const t0 = template('<div>', true /* root */)
  887. const Child = defineVaporComponent({
  888. setup() {
  889. const n = t0() as Element
  890. renderEffect(() => {
  891. // binding on template root generates incremental class setter
  892. setStyle(n, childStyle.value)
  893. })
  894. return n
  895. },
  896. })
  897. const Parent = defineVaporComponent({
  898. setup() {
  899. return createComponent(
  900. Child,
  901. {
  902. style: () => parentStyle.value,
  903. },
  904. null,
  905. true, // pass single root flag
  906. )
  907. },
  908. })
  909. const { host } = define({
  910. setup() {
  911. return createComponent(Parent, {
  912. style: () => rootStyle.value,
  913. })
  914. },
  915. }).render()
  916. const el = host.children[0] as HTMLElement
  917. function getCSS() {
  918. return el.style.cssText.replace(/\s+/g, '')
  919. }
  920. function assertStyles() {
  921. const css = getCSS()
  922. expect(css).toContain(stringifyStyle(rootStyle.value))
  923. if (parentStyle.value) {
  924. expect(css).toContain(stringifyStyle(parentStyle.value))
  925. }
  926. expect(css).toContain(stringifyStyle(childStyle.value))
  927. }
  928. assertStyles()
  929. rootStyle.value = { color: 'green' }
  930. await nextTick()
  931. assertStyles()
  932. expect(getCSS()).not.toContain('color:red')
  933. parentStyle.value = null
  934. await nextTick()
  935. assertStyles()
  936. expect(getCSS()).not.toContain('font-size:12px')
  937. childStyle.value = 'font-weight:500'
  938. await nextTick()
  939. assertStyles()
  940. expect(getCSS()).not.toContain('font-size:bold')
  941. })
  942. it('should fallthrough attrs to dynamic component', async () => {
  943. const Comp = defineVaporComponent({
  944. setup() {
  945. const n1 = createDynamicComponent(
  946. () => 'button',
  947. null,
  948. {
  949. default: () => {
  950. const n0 = createSlot('default', null)
  951. return n0
  952. },
  953. },
  954. true,
  955. )
  956. return n1
  957. },
  958. })
  959. const { html } = define({
  960. setup() {
  961. return createComponent(
  962. Comp,
  963. {
  964. class: () => 'foo',
  965. },
  966. null,
  967. true,
  968. )
  969. },
  970. }).render()
  971. expect(html()).toBe(
  972. '<button class="foo"><!--slot--></button><!--dynamic-component-->',
  973. )
  974. })
  975. it('parent value should take priority', async () => {
  976. const parentVal = ref('parent')
  977. const childVal = ref('child')
  978. const t0 = template('<div>', true /* root */)
  979. const Child = defineVaporComponent({
  980. setup() {
  981. const n = t0()
  982. renderEffect(() => {
  983. // prop bindings on template root generates extra `root: true` flag
  984. setProp(n, 'id', childVal.value)
  985. setProp(n, 'aria-x', childVal.value)
  986. setDynamicProps(n, [{ 'aria-y': childVal.value }])
  987. })
  988. return n
  989. },
  990. })
  991. const { host } = define({
  992. setup() {
  993. return createComponent(Child, {
  994. id: () => parentVal.value,
  995. 'aria-x': () => parentVal.value,
  996. 'aria-y': () => parentVal.value,
  997. })
  998. },
  999. }).render()
  1000. const el = host.children[0]
  1001. expect(el.id).toBe(parentVal.value)
  1002. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1003. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1004. childVal.value = 'child1'
  1005. await nextTick()
  1006. expect(el.id).toBe(parentVal.value)
  1007. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1008. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1009. parentVal.value = 'parent1'
  1010. await nextTick()
  1011. expect(el.id).toBe(parentVal.value)
  1012. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1013. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1014. })
  1015. it('empty string should not be passed to classList.add', async () => {
  1016. const t0 = template('<div>', true /* root */)
  1017. const Child = defineVaporComponent({
  1018. setup() {
  1019. const n = t0() as Element
  1020. renderEffect(() => {
  1021. setClass(n, {
  1022. foo: false,
  1023. })
  1024. })
  1025. return n
  1026. },
  1027. })
  1028. const Parent = defineVaporComponent({
  1029. setup() {
  1030. return createComponent(
  1031. Child,
  1032. {
  1033. class: () => ({
  1034. bar: false,
  1035. }),
  1036. },
  1037. null,
  1038. true,
  1039. )
  1040. },
  1041. })
  1042. const { host } = define({
  1043. setup() {
  1044. return createComponent(Parent)
  1045. },
  1046. }).render()
  1047. const el = host.children[0]
  1048. expect(el.classList.length).toBe(0)
  1049. })
  1050. })