componentAttrs.spec.ts 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201
  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. // @ts-expect-error
  138. Child.props = ['foo']
  139. const { host } = define(Hello).render()
  140. const node = host.children[0] as HTMLElement
  141. expect(node.getAttribute('id')).toBe('test')
  142. expect(node.getAttribute('foo')).toBe(null) // declared as prop
  143. expect(node.getAttribute('class')).toBe('c2 c0')
  144. expect(node.style.color).toBe('green')
  145. expect(node.style.fontWeight).toBe('bold')
  146. node.dispatchEvent(new CustomEvent('click'))
  147. expect(click).toHaveBeenCalled()
  148. await nextTick()
  149. expect(childUpdated).toHaveBeenCalled()
  150. expect(node.getAttribute('id')).toBe('test')
  151. expect(node.getAttribute('foo')).toBe(null)
  152. expect(node.getAttribute('class')).toBe('c2 c1')
  153. expect(node.style.color).toBe('red')
  154. expect(node.style.fontWeight).toBe('bold')
  155. })
  156. it('should fallthrough for nested components', async () => {
  157. const click = vi.fn()
  158. const childUpdated = vi.fn()
  159. const grandChildUpdated = vi.fn()
  160. const Hello = {
  161. setup() {
  162. const count = ref(0)
  163. function inc() {
  164. count.value++
  165. click()
  166. }
  167. return createComponent(Child, {
  168. foo: () => count.value + 1,
  169. id: () => 'test',
  170. class: () => 'c' + count.value,
  171. style: () => ({
  172. color: count.value ? 'red' : 'green',
  173. }),
  174. onClick: () => inc,
  175. })
  176. },
  177. }
  178. const Child = defineVaporComponent({
  179. setup(props: any) {
  180. onUpdated(childUpdated)
  181. // HOC simply passing props down.
  182. // this will result in merging the same attrs, but should be deduped by
  183. // `mergeProps`.
  184. return createComponent(GrandChild, props, null, true)
  185. },
  186. })
  187. const GrandChild = defineVaporComponent({
  188. props: {
  189. id: String,
  190. foo: Number,
  191. },
  192. setup(props) {
  193. onUpdated(grandChildUpdated)
  194. const n0 = template(
  195. '<div class="c2" style="font-weight: bold"></div>',
  196. true,
  197. )() as Element
  198. renderEffect(() => {
  199. setProp(n0, 'id', props.id)
  200. setElementText(n0, props.foo)
  201. })
  202. return n0
  203. },
  204. })
  205. const { host } = define(Hello).render()
  206. expect(host.innerHTML).toBe(
  207. '<div class="c2 c0" style="font-weight: bold; color: green;" id="test">1</div>',
  208. )
  209. const node = host.children[0] as HTMLElement
  210. // with declared props, any parent attr that isn't a prop falls through
  211. expect(node.getAttribute('id')).toBe('test')
  212. expect(node.getAttribute('class')).toBe('c2 c0')
  213. expect(node.style.color).toBe('green')
  214. expect(node.style.fontWeight).toBe('bold')
  215. node.dispatchEvent(new CustomEvent('click'))
  216. expect(click).toHaveBeenCalled()
  217. // ...while declared ones remain props
  218. expect(node.hasAttribute('foo')).toBe(false)
  219. await nextTick()
  220. // child should not update, due to it not accessing props
  221. // this is a optimization in vapor mode
  222. expect(childUpdated).not.toHaveBeenCalled()
  223. expect(grandChildUpdated).toHaveBeenCalled()
  224. expect(node.getAttribute('id')).toBe('test')
  225. expect(node.getAttribute('class')).toBe('c2 c1')
  226. expect(node.style.color).toBe('red')
  227. expect(node.style.fontWeight).toBe('bold')
  228. expect(node.hasAttribute('foo')).toBe(false)
  229. })
  230. it('should not fallthrough with inheritAttrs: false', () => {
  231. const Parent = defineVaporComponent({
  232. setup() {
  233. return createComponent(Child, { foo: () => 1, class: () => 'parent' })
  234. },
  235. })
  236. const Child = defineVaporComponent({
  237. props: ['foo'],
  238. inheritAttrs: false,
  239. setup(props) {
  240. const n0 = template('<div></div>', true)() as Element
  241. renderEffect(() => setElementText(n0, props.foo))
  242. return n0
  243. },
  244. })
  245. const { html } = define(Parent).render()
  246. // should not contain class
  247. expect(html()).toMatch(`<div>1</div>`)
  248. })
  249. it('explicit spreading with inheritAttrs: false', () => {
  250. const Parent = defineVaporComponent({
  251. setup() {
  252. return createComponent(Child, { foo: () => 1, class: () => 'parent' })
  253. },
  254. })
  255. const Child = defineVaporComponent({
  256. props: ['foo'],
  257. inheritAttrs: false,
  258. setup(props, { attrs }) {
  259. const n0 = template('<div>', true)() as Element
  260. renderEffect(() => {
  261. setElementText(n0, props.foo)
  262. setDynamicProps(n0, [{ class: 'child' }, attrs])
  263. })
  264. return n0
  265. },
  266. })
  267. const { html } = define(Parent).render()
  268. // should merge parent/child classes
  269. expect(html()).toMatch(`<div class="child parent">1</div>`)
  270. })
  271. it('should warn when fallthrough fails on non-single-root', () => {
  272. const Parent = {
  273. setup() {
  274. return createComponent(Child, {
  275. foo: () => 1,
  276. class: () => 'parent',
  277. onBar: () => () => {},
  278. })
  279. },
  280. }
  281. const Child = defineVaporComponent({
  282. props: ['foo'],
  283. render() {
  284. return [template('<div></div>')(), template('<div></div>')()]
  285. },
  286. })
  287. define(Parent).render()
  288. expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
  289. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  290. })
  291. it('should warn when fallthrough fails on teleport root node', () => {
  292. const Parent = {
  293. render() {
  294. return createComponent(Child, { class: () => 'parent' })
  295. },
  296. }
  297. const target = document.createElement('div')
  298. const Child = defineVaporComponent({
  299. render() {
  300. return createComponent(
  301. VaporTeleport,
  302. { to: () => target },
  303. {
  304. default: () => template('<div></div>')(),
  305. },
  306. )
  307. },
  308. })
  309. document.body.appendChild(target)
  310. define(Parent).render()
  311. expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
  312. })
  313. it('should dedupe same listeners when $attrs is used during render', () => {
  314. const click = vi.fn()
  315. const count = ref(0)
  316. function inc() {
  317. count.value++
  318. click()
  319. }
  320. const Parent = {
  321. render() {
  322. return createComponent(Child, { onClick: () => inc })
  323. },
  324. }
  325. const Child = defineVaporComponent({
  326. setup(_, { attrs }) {
  327. const n0 = template('<div></div>', true)() as any
  328. n0.$evtclick = withModifiers(() => {}, ['prevent', 'stop'])
  329. renderEffect(() => setDynamicProps(n0, [attrs]))
  330. return n0
  331. },
  332. })
  333. const { host } = define(Parent).render()
  334. const node = host.children[0] as HTMLElement
  335. node.dispatchEvent(new CustomEvent('click'))
  336. expect(click).toHaveBeenCalledTimes(1)
  337. expect(count.value).toBe(1)
  338. })
  339. it('should not warn when context.attrs is used during render', () => {
  340. const Parent = {
  341. render() {
  342. return createComponent(Child, {
  343. foo: () => 1,
  344. class: () => 'parent',
  345. onBar: () => () => {},
  346. })
  347. },
  348. }
  349. const Child = defineVaporComponent({
  350. props: ['foo'],
  351. render(_ctx, $props, $emit, $attrs, $slots) {
  352. const n0 = template('<div></div>')() as Element
  353. const n1 = template('<div></div>')() as Element
  354. renderEffect(() => {
  355. setDynamicProps(n1, [$attrs])
  356. })
  357. return [n0, n1]
  358. },
  359. })
  360. const { html } = define(Parent).render()
  361. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  362. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  363. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  364. })
  365. it('should not warn when context.attrs is used during render (functional)', () => {
  366. const Parent = {
  367. render() {
  368. return createComponent(Child, {
  369. foo: () => 1,
  370. class: () => 'parent',
  371. onBar: () => () => {},
  372. })
  373. },
  374. }
  375. const { component: Child } = define((_: any, { attrs }: any) => {
  376. const n0 = template('<div></div>')() as Element
  377. const n1 = template('<div></div>')() as Element
  378. renderEffect(() => {
  379. setDynamicProps(n1, [attrs])
  380. })
  381. return [n0, n1]
  382. })
  383. Child.props = ['foo']
  384. const { html } = define(Parent).render()
  385. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  386. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  387. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  388. })
  389. it('should not warn when functional component has optional props', () => {
  390. const Parent = {
  391. render() {
  392. return createComponent(Child, {
  393. foo: () => 1,
  394. class: () => 'parent',
  395. onBar: () => () => {},
  396. })
  397. },
  398. }
  399. const { component: Child } = define((props: any) => {
  400. const n0 = template('<div></div>')() as Element
  401. const n1 = template('<div></div>')() as Element
  402. renderEffect(() => {
  403. setClass(n1, props.class)
  404. })
  405. return [n0, n1]
  406. })
  407. const { html } = define(Parent).render()
  408. expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
  409. expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
  410. expect(html()).toBe(`<div></div><div class="parent"></div>`)
  411. })
  412. it('should warn when functional component has props and does not use attrs', () => {
  413. const Parent = {
  414. render() {
  415. return createComponent(Child, {
  416. foo: () => 1,
  417. class: () => 'parent',
  418. onBar: () => () => {},
  419. })
  420. },
  421. }
  422. const { component: Child } = define(() => [
  423. template('<div></div>')(),
  424. template('<div></div>')(),
  425. ])
  426. Child.props = ['foo']
  427. const { html } = define(Parent).render()
  428. expect(`Extraneous non-props attributes`).toHaveBeenWarned()
  429. expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
  430. expect(html()).toBe(`<div></div><div></div>`)
  431. })
  432. it('should not let listener fallthrough when declared in emits (stateful)', () => {
  433. const Child = defineVaporComponent({
  434. emits: ['click'],
  435. render(_ctx, $props, $emit, $attrs, $slots) {
  436. const n0 = template('<button>hello</button>')() as any
  437. n0.$evtclick = () => {
  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. components: { GrandChild },
  598. render(_ctx, $props, $emit, $attrs, $slots) {
  599. const n0 = template('<div></div>')() as any
  600. setInsertionState(n0)
  601. createComponent(GrandChild, null, {
  602. default: () => {
  603. const n1 = template(' ')()
  604. renderEffect(() => setElementText(n1, $attrs.foo))
  605. return n1
  606. },
  607. })
  608. return n0
  609. },
  610. })
  611. const obj = ref(1)
  612. const App = defineVaporComponent({
  613. render() {
  614. return createComponent(Child, { foo: () => obj.value })
  615. },
  616. })
  617. const { html } = define(App).render()
  618. expect(html()).toBe('<div foo="1">1<!--slot--></div>')
  619. obj.value = 2
  620. await nextTick()
  621. expect(html()).toBe('<div foo="2">2<!--slot--></div>')
  622. })
  623. it('should allow attrs to fallthrough on component with comment at root', async () => {
  624. const t0 = template('<!--comment-->')
  625. const t1 = template('<div>')
  626. const { component: Child } = define({
  627. props: ['foo'],
  628. setup(props: any) {
  629. const n0 = t0()
  630. const n1 = t1()
  631. renderEffect(() => setElementText(n1, props.foo))
  632. return [n0, n1]
  633. },
  634. })
  635. const foo = ref(1)
  636. const id = ref('a')
  637. const { host } = define({
  638. setup() {
  639. return createComponent(
  640. Child,
  641. {
  642. foo: () => foo.value,
  643. id: () => id.value,
  644. },
  645. null,
  646. true,
  647. )
  648. },
  649. }).render()
  650. expect(host.innerHTML).toBe('<!--comment--><div id="a">1</div>')
  651. foo.value++
  652. await nextTick()
  653. expect(host.innerHTML).toBe('<!--comment--><div id="a">2</div>')
  654. id.value = 'b'
  655. await nextTick()
  656. expect(host.innerHTML).toBe('<!--comment--><div id="b">2</div>')
  657. })
  658. it('if block', async () => {
  659. const t0 = template('<div>foo</div>', true)
  660. const t1 = template('<div>bar</div>', true)
  661. const t2 = template('<div>baz</div>', true)
  662. const { component: Child } = define({
  663. setup() {
  664. const n0 = createIf(
  665. () => true,
  666. () => {
  667. const n2 = t0()
  668. return n2
  669. },
  670. () =>
  671. createIf(
  672. () => false,
  673. () => {
  674. const n4 = t1()
  675. return n4
  676. },
  677. () => {
  678. const n7 = t2()
  679. return n7
  680. },
  681. ),
  682. )
  683. return n0
  684. },
  685. })
  686. const id = ref('a')
  687. const { host } = define({
  688. setup() {
  689. return createComponent(
  690. Child,
  691. {
  692. id: () => id.value,
  693. },
  694. null,
  695. true,
  696. )
  697. },
  698. }).render()
  699. expect(host.innerHTML).toBe('<div id="a">foo</div><!--if-->')
  700. })
  701. it('should not allow attrs to fallthrough on component with multiple roots', async () => {
  702. const t0 = template('<span>')
  703. const t1 = template('<div>')
  704. const { component: Child } = define({
  705. props: ['foo'],
  706. setup(props: any) {
  707. const n0 = t0()
  708. const n1 = t1()
  709. renderEffect(() => setElementText(n1, props.foo))
  710. return [n0, n1]
  711. },
  712. })
  713. const foo = ref(1)
  714. const id = ref('a')
  715. const { host } = define({
  716. setup() {
  717. return createComponent(
  718. Child,
  719. {
  720. foo: () => foo.value,
  721. id: () => id.value,
  722. },
  723. null,
  724. true,
  725. )
  726. },
  727. }).render()
  728. expect(host.innerHTML).toBe('<span></span><div>1</div>')
  729. expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
  730. })
  731. it('should not allow attrs to fallthrough on component with single comment root', async () => {
  732. const t0 = template('<!--comment-->')
  733. const { component: Child } = define({
  734. setup() {
  735. const n0 = t0()
  736. return [n0]
  737. },
  738. })
  739. const id = ref('a')
  740. const { host } = define({
  741. setup() {
  742. return createComponent(Child, { id: () => id.value }, null, true)
  743. },
  744. }).render()
  745. expect(host.innerHTML).toBe('<!--comment-->')
  746. expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
  747. })
  748. it('should not fallthrough if explicitly pass inheritAttrs: false', async () => {
  749. const t0 = template('<div>', true)
  750. const { component: Child } = define({
  751. props: ['foo'],
  752. inheritAttrs: false,
  753. setup(props: any) {
  754. const n0 = t0() as Element
  755. renderEffect(() => setElementText(n0, props.foo))
  756. return n0
  757. },
  758. })
  759. const foo = ref(1)
  760. const id = ref('a')
  761. const { host } = define({
  762. setup() {
  763. return createComponent(
  764. Child,
  765. {
  766. foo: () => foo.value,
  767. id: () => id.value,
  768. },
  769. null,
  770. true,
  771. )
  772. },
  773. }).render()
  774. expect(host.innerHTML).toBe('<div>1</div>')
  775. foo.value++
  776. await nextTick()
  777. expect(host.innerHTML).toBe('<div>2</div>')
  778. id.value = 'b'
  779. await nextTick()
  780. expect(host.innerHTML).toBe('<div>2</div>')
  781. })
  782. it('should pass through attrs in nested single root components', async () => {
  783. const t0 = template('<div>', true)
  784. const { component: Grandson } = define({
  785. props: ['custom-attr'],
  786. setup(_: any, { attrs }: any) {
  787. const n0 = t0() as Element
  788. renderEffect(() => setElementText(n0, attrs.foo))
  789. return n0
  790. },
  791. })
  792. const { component: Child } = define({
  793. setup() {
  794. const n0 = createComponent(
  795. Grandson,
  796. {
  797. 'custom-attr': () => 'custom-attr',
  798. },
  799. null,
  800. true,
  801. )
  802. return n0
  803. },
  804. })
  805. const foo = ref(1)
  806. const id = ref('a')
  807. const { host } = define({
  808. setup() {
  809. return createComponent(
  810. Child,
  811. {
  812. foo: () => foo.value,
  813. id: () => id.value,
  814. },
  815. null,
  816. true,
  817. )
  818. },
  819. }).render()
  820. expect(host.innerHTML).toBe('<div foo="1" id="a">1</div>')
  821. foo.value++
  822. await nextTick()
  823. expect(host.innerHTML).toBe('<div foo="2" id="a">2</div>')
  824. id.value = 'b'
  825. await nextTick()
  826. expect(host.innerHTML).toBe('<div foo="2" id="b">2</div>')
  827. })
  828. it('should merge classes', async () => {
  829. const rootClass = ref('root')
  830. const parentClass = ref('parent')
  831. const childClass = ref('child')
  832. const t0 = template('<div>', true /* root */)
  833. const Child = defineVaporComponent({
  834. setup() {
  835. const n = t0() as Element
  836. renderEffect(() => {
  837. // binding on template root generates incremental class setter
  838. setClass(n, childClass.value)
  839. })
  840. return n
  841. },
  842. })
  843. const Parent = defineVaporComponent({
  844. setup() {
  845. return createComponent(
  846. Child,
  847. {
  848. class: () => parentClass.value,
  849. },
  850. null,
  851. true, // pass single root flag
  852. )
  853. },
  854. })
  855. const { host } = define({
  856. setup() {
  857. return createComponent(Parent, {
  858. class: () => rootClass.value,
  859. })
  860. },
  861. }).render()
  862. const list = host.children[0].classList
  863. // assert classes without being order-sensitive
  864. function assertClasses(cls: string[]) {
  865. expect(list.length).toBe(cls.length)
  866. for (const c of cls) {
  867. expect(list.contains(c)).toBe(true)
  868. }
  869. }
  870. assertClasses(['root', 'parent', 'child'])
  871. rootClass.value = 'root1'
  872. await nextTick()
  873. assertClasses(['root1', 'parent', 'child'])
  874. parentClass.value = 'parent1'
  875. await nextTick()
  876. assertClasses(['root1', 'parent1', 'child'])
  877. childClass.value = 'child1'
  878. await nextTick()
  879. assertClasses(['root1', 'parent1', 'child1'])
  880. })
  881. it('should merge styles', async () => {
  882. const rootStyle: Ref<string | Record<string, string>> = ref('color:red')
  883. const parentStyle: Ref<string | null> = ref('font-size:12px')
  884. const childStyle = ref('font-weight:bold')
  885. const t0 = template('<div>', true /* root */)
  886. const Child = defineVaporComponent({
  887. setup() {
  888. const n = t0() as Element
  889. renderEffect(() => {
  890. // binding on template root generates incremental class setter
  891. setStyle(n, childStyle.value)
  892. })
  893. return n
  894. },
  895. })
  896. const Parent = defineVaporComponent({
  897. setup() {
  898. return createComponent(
  899. Child,
  900. {
  901. style: () => parentStyle.value,
  902. },
  903. null,
  904. true, // pass single root flag
  905. )
  906. },
  907. })
  908. const { host } = define({
  909. setup() {
  910. return createComponent(Parent, {
  911. style: () => rootStyle.value,
  912. })
  913. },
  914. }).render()
  915. const el = host.children[0] as HTMLElement
  916. function getCSS() {
  917. return el.style.cssText.replace(/\s+/g, '')
  918. }
  919. function assertStyles() {
  920. const css = getCSS()
  921. expect(css).toContain(stringifyStyle(rootStyle.value))
  922. if (parentStyle.value) {
  923. expect(css).toContain(stringifyStyle(parentStyle.value))
  924. }
  925. expect(css).toContain(stringifyStyle(childStyle.value))
  926. }
  927. assertStyles()
  928. rootStyle.value = { color: 'green' }
  929. await nextTick()
  930. assertStyles()
  931. expect(getCSS()).not.toContain('color:red')
  932. parentStyle.value = null
  933. await nextTick()
  934. assertStyles()
  935. expect(getCSS()).not.toContain('font-size:12px')
  936. childStyle.value = 'font-weight:500'
  937. await nextTick()
  938. assertStyles()
  939. expect(getCSS()).not.toContain('font-size:bold')
  940. })
  941. it('should fallthrough attrs to dynamic component', async () => {
  942. const Comp = defineVaporComponent({
  943. setup() {
  944. const n1 = createDynamicComponent(
  945. () => 'button',
  946. null,
  947. {
  948. default: () => {
  949. const n0 = createSlot('default', null)
  950. return n0
  951. },
  952. },
  953. true,
  954. )
  955. return n1
  956. },
  957. })
  958. const { html } = define({
  959. setup() {
  960. return createComponent(
  961. Comp,
  962. {
  963. class: () => 'foo',
  964. },
  965. null,
  966. true,
  967. )
  968. },
  969. }).render()
  970. expect(html()).toBe(
  971. '<button class="foo"><!--slot--></button><!--dynamic-component-->',
  972. )
  973. })
  974. it('parent value should take priority', async () => {
  975. const parentVal = ref('parent')
  976. const childVal = ref('child')
  977. const t0 = template('<div>', true /* root */)
  978. const Child = defineVaporComponent({
  979. setup() {
  980. const n = t0()
  981. renderEffect(() => {
  982. // prop bindings on template root generates extra `root: true` flag
  983. setProp(n, 'id', childVal.value)
  984. setProp(n, 'aria-x', childVal.value)
  985. setDynamicProps(n, [{ 'aria-y': childVal.value }])
  986. })
  987. return n
  988. },
  989. })
  990. const { host } = define({
  991. setup() {
  992. return createComponent(Child, {
  993. id: () => parentVal.value,
  994. 'aria-x': () => parentVal.value,
  995. 'aria-y': () => parentVal.value,
  996. })
  997. },
  998. }).render()
  999. const el = host.children[0]
  1000. expect(el.id).toBe(parentVal.value)
  1001. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1002. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1003. childVal.value = 'child1'
  1004. await nextTick()
  1005. expect(el.id).toBe(parentVal.value)
  1006. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1007. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1008. parentVal.value = 'parent1'
  1009. await nextTick()
  1010. expect(el.id).toBe(parentVal.value)
  1011. expect(el.getAttribute('aria-x')).toBe(parentVal.value)
  1012. expect(el.getAttribute('aria-y')).toBe(parentVal.value)
  1013. })
  1014. it('empty string should not be passed to classList.add', async () => {
  1015. const t0 = template('<div>', true /* root */)
  1016. const Child = defineVaporComponent({
  1017. setup() {
  1018. const n = t0() as Element
  1019. renderEffect(() => {
  1020. setClass(n, {
  1021. foo: false,
  1022. })
  1023. })
  1024. return n
  1025. },
  1026. })
  1027. const Parent = defineVaporComponent({
  1028. setup() {
  1029. return createComponent(
  1030. Child,
  1031. {
  1032. class: () => ({
  1033. bar: false,
  1034. }),
  1035. },
  1036. null,
  1037. true,
  1038. )
  1039. },
  1040. })
  1041. const { host } = define({
  1042. setup() {
  1043. return createComponent(Parent)
  1044. },
  1045. }).render()
  1046. const el = host.children[0]
  1047. expect(el.classList.length).toBe(0)
  1048. })
  1049. })