component.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. import {
  2. type EffectScope,
  3. ReactiveEffect,
  4. type Ref,
  5. inject,
  6. nextTick,
  7. onBeforeMount,
  8. onMounted,
  9. onUpdated,
  10. provide,
  11. ref,
  12. toDisplayString,
  13. useAttrs,
  14. watch,
  15. watchEffect,
  16. } from '@vue/runtime-dom'
  17. import {
  18. createComponent,
  19. createIf,
  20. createTextNode,
  21. defineVaporComponent,
  22. renderEffect,
  23. setInsertionState,
  24. template,
  25. txt,
  26. } from '../src'
  27. import { compileToVaporRender, makeRender } from './_utils'
  28. import type { VaporComponentInstance } from '../src/component'
  29. import { setElementText, setText } from '../src/dom/prop'
  30. const define = makeRender()
  31. describe('component', () => {
  32. it('should update parent(hoc) component host el when child component self update', async () => {
  33. const value = ref(true)
  34. let childNode1: Node | null = null
  35. let childNode2: Node | null = null
  36. const { component: Child } = define({
  37. setup() {
  38. return createIf(
  39. () => value.value,
  40. () => (childNode1 = template('<div></div>')()),
  41. () => (childNode2 = template('<span></span>')()),
  42. )
  43. },
  44. })
  45. const { host } = define({
  46. setup() {
  47. return createComponent(Child)
  48. },
  49. }).render()
  50. expect(host.innerHTML).toBe('<div></div><!--if-->')
  51. expect(host.children[0]).toBe(childNode1)
  52. value.value = false
  53. await nextTick()
  54. expect(host.innerHTML).toBe('<span></span><!--if-->')
  55. expect(host.children[0]).toBe(childNode2)
  56. })
  57. it('should create a component with props', () => {
  58. const { component: Comp } = define({
  59. setup() {
  60. return template('<div>', true)()
  61. },
  62. })
  63. const { host } = define({
  64. setup() {
  65. return createComponent(Comp, { id: () => 'foo', class: () => 'bar' })
  66. },
  67. }).render()
  68. expect(host.innerHTML).toBe('<div id="foo" class="bar"></div>')
  69. })
  70. it('should not update Component if only changed props are declared emit listeners', async () => {
  71. const updatedSyp = vi.fn()
  72. const { component: Comp } = define({
  73. emits: ['foo'],
  74. setup() {
  75. onUpdated(updatedSyp)
  76. return template('<div>', true)()
  77. },
  78. })
  79. const toggle = ref(true)
  80. const fn1 = () => {}
  81. const fn2 = () => {}
  82. define({
  83. setup() {
  84. const _on_foo = () => (toggle.value ? fn1() : fn2())
  85. return createComponent(Comp, { onFoo: () => _on_foo })
  86. },
  87. }).render()
  88. expect(updatedSyp).toHaveBeenCalledTimes(0)
  89. toggle.value = false
  90. await nextTick()
  91. expect(updatedSyp).toHaveBeenCalledTimes(0)
  92. })
  93. it('component child synchronously updating parent state should trigger parent re-render', async () => {
  94. const { component: Child } = define({
  95. setup() {
  96. const n = inject<Ref<number>>('foo')!
  97. n.value++
  98. const n0 = template('<div></div>')()
  99. renderEffect(() => setElementText(n0, n.value))
  100. return n0
  101. },
  102. })
  103. const { host } = define({
  104. setup() {
  105. const n = ref(0)
  106. provide('foo', n)
  107. const n0 = template('<div></div>')()
  108. renderEffect(() => setElementText(n0, n.value))
  109. return [n0, createComponent(Child)]
  110. },
  111. }).render()
  112. expect(host.innerHTML).toBe('<div>0</div><div>1</div>')
  113. await nextTick()
  114. expect(host.innerHTML).toBe('<div>1</div><div>1</div>')
  115. })
  116. it('component child updating parent state in pre-flush should trigger parent re-render', async () => {
  117. const { component: Child } = define({
  118. props: ['value'],
  119. setup(props: any, { emit }) {
  120. watch(
  121. () => props.value,
  122. val => emit('update', val),
  123. )
  124. const n0 = template('<div></div>')()
  125. renderEffect(() => setElementText(n0, props.value))
  126. return n0
  127. },
  128. })
  129. const outer = ref(0)
  130. const { host } = define({
  131. setup() {
  132. const inner = ref(0)
  133. const n0 = template('<div></div>')()
  134. renderEffect(() => setElementText(n0, inner.value))
  135. const n1 = createComponent(Child, {
  136. value: () => outer.value,
  137. onUpdate: () => (val: number) => (inner.value = val),
  138. })
  139. return [n0, n1]
  140. },
  141. }).render()
  142. expect(host.innerHTML).toBe('<div>0</div><div>0</div>')
  143. outer.value++
  144. await nextTick()
  145. expect(host.innerHTML).toBe('<div>1</div><div>1</div>')
  146. })
  147. it('events in dynamic props', async () => {
  148. const { component: Child } = define({
  149. props: ['count'],
  150. setup(props: any, { emit }) {
  151. emit('update', props.count + 1)
  152. const n0 = template('<div></div>')()
  153. renderEffect(() => setElementText(n0, props.count))
  154. return n0
  155. },
  156. })
  157. const count = ref(0)
  158. const { host } = define({
  159. setup() {
  160. const n0 = createComponent(Child, {
  161. $: [
  162. () => ({
  163. count: count.value,
  164. }),
  165. { onUpdate: () => (val: number) => (count.value = val) },
  166. ],
  167. })
  168. return n0
  169. },
  170. }).render()
  171. expect(host.innerHTML).toBe('<div>1</div>')
  172. })
  173. it('child only updates once when triggered in multiple ways', async () => {
  174. const a = ref(0)
  175. const calls: string[] = []
  176. const { component: Child } = define({
  177. props: ['count'],
  178. setup(props: any) {
  179. onUpdated(() => calls.push('update child'))
  180. const n = createTextNode()
  181. renderEffect(() => {
  182. setText(n, `${props.count} - ${a.value}`)
  183. })
  184. return n
  185. },
  186. })
  187. const { host } = define({
  188. setup() {
  189. return createComponent(Child, { count: () => a.value })
  190. },
  191. }).render()
  192. expect(host.innerHTML).toBe('0 - 0')
  193. expect(calls).toEqual([])
  194. // This will trigger child rendering directly, as well as via a prop change
  195. a.value++
  196. await nextTick()
  197. expect(host.innerHTML).toBe('1 - 1')
  198. expect(calls).toEqual(['update child'])
  199. })
  200. it(`an earlier update doesn't lead to excessive subsequent updates`, async () => {
  201. const globalCount = ref(0)
  202. const parentCount = ref(0)
  203. const calls: string[] = []
  204. const { component: Child } = define({
  205. props: ['count'],
  206. setup(props: any) {
  207. watch(
  208. () => props.count,
  209. () => {
  210. calls.push('child watcher')
  211. globalCount.value = props.count
  212. },
  213. )
  214. onUpdated(() => calls.push('update child'))
  215. return []
  216. },
  217. })
  218. const { component: Parent } = define({
  219. props: ['count'],
  220. setup(props: any) {
  221. onUpdated(() => calls.push('update parent'))
  222. const n1 = createTextNode()
  223. const n2 = createComponent(Child, { count: () => parentCount.value })
  224. renderEffect(() => {
  225. setText(n1, `${globalCount.value} - ${props.count}`)
  226. })
  227. return [n1, n2]
  228. },
  229. })
  230. const { host } = define({
  231. setup() {
  232. onUpdated(() => calls.push('update root'))
  233. return createComponent(Parent, { count: () => globalCount.value })
  234. },
  235. }).render()
  236. expect(host.innerHTML).toBe(`0 - 0`)
  237. expect(calls).toEqual([])
  238. parentCount.value++
  239. await nextTick()
  240. expect(host.innerHTML).toBe(`1 - 1`)
  241. expect(calls).toEqual(['child watcher', 'update parent'])
  242. })
  243. it('child component props update should not lead to double update', async () => {
  244. const text = ref(0)
  245. const spy = vi.fn()
  246. const { component: Comp } = define({
  247. props: ['text'],
  248. setup(props: any) {
  249. const n1 = template('<h1></h1>')()
  250. renderEffect(() => {
  251. spy()
  252. setElementText(n1, props.text)
  253. })
  254. return n1
  255. },
  256. })
  257. const { host } = define({
  258. setup() {
  259. return createComponent(Comp, { text: () => text.value })
  260. },
  261. }).render()
  262. expect(host.innerHTML).toBe('<h1>0</h1>')
  263. expect(spy).toHaveBeenCalledTimes(1)
  264. text.value++
  265. await nextTick()
  266. expect(host.innerHTML).toBe('<h1>1</h1>')
  267. expect(spy).toHaveBeenCalledTimes(2)
  268. })
  269. it('properly mount child component when using setInsertionState', async () => {
  270. const spy = vi.fn()
  271. const { component: Comp } = define({
  272. setup() {
  273. onMounted(spy)
  274. return template('<h1>hi</h1>')()
  275. },
  276. })
  277. const { host } = define({
  278. setup() {
  279. const n2 = template('<div></div>', true)()
  280. setInsertionState(n2 as any)
  281. createComponent(Comp)
  282. return n2
  283. },
  284. }).render()
  285. expect(host.innerHTML).toBe('<div><h1>hi</h1></div>')
  286. expect(spy).toHaveBeenCalledTimes(1)
  287. })
  288. it('unmount component', async () => {
  289. const { host, app, instance } = define(() => {
  290. const count = ref(0)
  291. const t0 = template('<div></div>')
  292. const n0 = t0()
  293. watchEffect(() => {
  294. setElementText(n0, count.value)
  295. })
  296. renderEffect(() => {})
  297. return n0
  298. }).render()
  299. const i = instance as VaporComponentInstance
  300. // watchEffect + renderEffect + props validation effect
  301. expect(getEffectsCount(i.scope)).toBe(3)
  302. expect(host.innerHTML).toBe('<div>0</div>')
  303. app.unmount()
  304. expect(host.innerHTML).toBe('')
  305. expect(getEffectsCount(i.scope)).toBe(0)
  306. })
  307. it('work with v-once + props', () => {
  308. const Child = defineVaporComponent({
  309. props: {
  310. count: Number,
  311. },
  312. setup(props) {
  313. const n0 = template(' ')() as any
  314. renderEffect(() => setText(n0, String(props.count)))
  315. return n0
  316. },
  317. })
  318. const count = ref(0)
  319. const { html } = define({
  320. setup() {
  321. return createComponent(
  322. Child,
  323. { count: () => count.value },
  324. null,
  325. true,
  326. true, // v-once
  327. )
  328. },
  329. }).render()
  330. expect(html()).toBe('0')
  331. count.value++
  332. expect(html()).toBe('0')
  333. })
  334. it('work with v-once + attrs', () => {
  335. const Child = defineVaporComponent({
  336. setup() {
  337. const attrs = useAttrs()
  338. const n0 = template(' ')() as any
  339. renderEffect(() => setText(n0, attrs.count as string))
  340. return n0
  341. },
  342. })
  343. const count = ref(0)
  344. const { html } = define({
  345. setup() {
  346. return createComponent(
  347. Child,
  348. { count: () => count.value },
  349. null,
  350. true,
  351. true, // v-once
  352. )
  353. },
  354. }).render()
  355. expect(html()).toBe('0')
  356. count.value++
  357. expect(html()).toBe('0')
  358. })
  359. it('v-once props should be frozen and not update when parent changes', async () => {
  360. const localCount = ref(0)
  361. const Child = defineVaporComponent({
  362. props: {
  363. count: Number,
  364. },
  365. setup(props) {
  366. const n0 = template('<div></div>')() as any
  367. renderEffect(() =>
  368. setElementText(n0, `${localCount.value} - ${props.count}`),
  369. )
  370. return n0
  371. },
  372. })
  373. const parentCount = ref(0)
  374. const { html } = define({
  375. setup() {
  376. return createComponent(
  377. Child,
  378. { count: () => parentCount.value },
  379. null,
  380. true,
  381. true, // v-once
  382. )
  383. },
  384. }).render()
  385. expect(html()).toBe('<div>0 - 0</div>')
  386. parentCount.value++
  387. await nextTick()
  388. expect(html()).toBe('<div>0 - 0</div>')
  389. localCount.value++
  390. await nextTick()
  391. expect(html()).toBe('<div>1 - 0</div>')
  392. })
  393. it('v-once attrs should be frozen and not update when parent changes', async () => {
  394. const localCount = ref(0)
  395. const Child = defineVaporComponent({
  396. inheritAttrs: false,
  397. setup() {
  398. const attrs = useAttrs()
  399. const n0 = template('<div></div>')() as any
  400. renderEffect(() =>
  401. setElementText(n0, `${localCount.value} - ${attrs.count}`),
  402. )
  403. return n0
  404. },
  405. })
  406. const parentCount = ref(0)
  407. const { html } = define({
  408. setup() {
  409. return createComponent(
  410. Child,
  411. { count: () => parentCount.value },
  412. null,
  413. true,
  414. true, // v-once
  415. )
  416. },
  417. }).render()
  418. expect(html()).toBe('<div>0 - 0</div>')
  419. parentCount.value++
  420. await nextTick()
  421. expect(html()).toBe('<div>0 - 0</div>')
  422. localCount.value++
  423. await nextTick()
  424. expect(html()).toBe('<div>1 - 0</div>')
  425. })
  426. test('should mount component only with template in production mode', () => {
  427. __DEV__ = false
  428. try {
  429. const { component: Child } = define({
  430. render() {
  431. return template('<div> HI </div>', true)()
  432. },
  433. })
  434. const { host } = define({
  435. setup() {
  436. return createComponent(Child, null, null, true)
  437. },
  438. }).render()
  439. expect(host.innerHTML).toBe('<div> HI </div>')
  440. } finally {
  441. __DEV__ = true
  442. }
  443. })
  444. test('should pass slot args to template-only component render in production mode', () => {
  445. __DEV__ = false
  446. try {
  447. const { component: Child } = define({
  448. render: compileToVaporRender(
  449. `<span v-if="$slots.default"><slot /></span>`,
  450. { bindingMetadata: {} },
  451. ),
  452. })
  453. const { host } = define({
  454. setup() {
  455. return createComponent(Child, null, {
  456. default: () => template('<button>slot</button>')(),
  457. })
  458. },
  459. }).render()
  460. expect(host.innerHTML).toBe('<span><button>slot</button></span>')
  461. } finally {
  462. __DEV__ = true
  463. }
  464. })
  465. it('warn if functional vapor component not return a block', () => {
  466. // @ts-expect-error
  467. define(() => {
  468. return () => {}
  469. }).render()
  470. expect(
  471. 'Functional vapor component must return a block directly',
  472. ).toHaveBeenWarned()
  473. })
  474. it('warn if setup return a function and no render function', () => {
  475. define({
  476. setup() {
  477. return () => []
  478. },
  479. }).render()
  480. expect(
  481. 'Vapor component setup() returned non-block value, and has no render function',
  482. ).toHaveBeenWarned()
  483. })
  484. it('warn non-existent property access', () => {
  485. define({
  486. setup() {
  487. return {}
  488. },
  489. render(ctx: any) {
  490. ctx.foo
  491. return []
  492. },
  493. }).render()
  494. expect(
  495. 'Property "foo" was accessed during render but is not defined on instance.',
  496. ).toHaveBeenWarned()
  497. })
  498. test('display attrs', () => {
  499. const App = defineVaporComponent({
  500. props: {},
  501. emits: [],
  502. setup(props, { attrs }) {
  503. const n0 = template('<div> ')() as any
  504. const x0 = txt(n0) as any
  505. renderEffect(() => setText(x0, toDisplayString(attrs)))
  506. return n0
  507. },
  508. })
  509. const { render } = define(App)
  510. expect(render).not.toThrow(TypeError)
  511. expect(
  512. 'Unhandled error during execution of setup function',
  513. ).not.toHaveBeenWarned()
  514. })
  515. it('should invalidate pending mounted hooks when unmounted before flush', async () => {
  516. const mountedSpy = vi.fn()
  517. const show = ref(false)
  518. const Child = defineVaporComponent({
  519. setup() {
  520. onBeforeMount(() => {
  521. show.value = false
  522. })
  523. onMounted(mountedSpy)
  524. return template('<div>child</div>')()
  525. },
  526. })
  527. define({
  528. setup() {
  529. return createIf(
  530. () => show.value,
  531. () => createComponent(Child),
  532. )
  533. },
  534. }).render()
  535. expect(mountedSpy).toHaveBeenCalledTimes(0)
  536. show.value = true
  537. await nextTick()
  538. expect(mountedSpy).toHaveBeenCalledTimes(0)
  539. })
  540. })
  541. function getEffectsCount(scope: EffectScope): number {
  542. let n = 0
  543. for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) {
  544. if (dep.dep instanceof ReactiveEffect) {
  545. n++
  546. }
  547. }
  548. return n
  549. }