component.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import {
  2. type EffectScope,
  3. ReactiveEffect,
  4. type Ref,
  5. inject,
  6. nextTick,
  7. onMounted,
  8. onUpdated,
  9. provide,
  10. ref,
  11. watch,
  12. watchEffect,
  13. } from '@vue/runtime-dom'
  14. import {
  15. createComponent,
  16. createIf,
  17. createTextNode,
  18. renderEffect,
  19. setInsertionState,
  20. template,
  21. } from '../src'
  22. import { makeRender } from './_utils'
  23. import type { VaporComponentInstance } from '../src/component'
  24. import { setElementText, setText } from '../src/dom/prop'
  25. const define = makeRender()
  26. describe('component', () => {
  27. it('should update parent(hoc) component host el when child component self update', async () => {
  28. const value = ref(true)
  29. let childNode1: Node | null = null
  30. let childNode2: Node | null = null
  31. const { component: Child } = define({
  32. setup() {
  33. return createIf(
  34. () => value.value,
  35. () => (childNode1 = template('<div></div>')()),
  36. () => (childNode2 = template('<span></span>')()),
  37. )
  38. },
  39. })
  40. const { host } = define({
  41. setup() {
  42. return createComponent(Child)
  43. },
  44. }).render()
  45. expect(host.innerHTML).toBe('<div></div><!--if-->')
  46. expect(host.children[0]).toBe(childNode1)
  47. value.value = false
  48. await nextTick()
  49. expect(host.innerHTML).toBe('<span></span><!--if-->')
  50. expect(host.children[0]).toBe(childNode2)
  51. })
  52. it('should create a component with props', () => {
  53. const { component: Comp } = define({
  54. setup() {
  55. return template('<div>', true)()
  56. },
  57. })
  58. const { host } = define({
  59. setup() {
  60. return createComponent(Comp, { id: () => 'foo', class: () => 'bar' })
  61. },
  62. }).render()
  63. expect(host.innerHTML).toBe('<div id="foo" class="bar"></div>')
  64. })
  65. it('should not update Component if only changed props are declared emit listeners', async () => {
  66. const updatedSyp = vi.fn()
  67. const { component: Comp } = define({
  68. emits: ['foo'],
  69. setup() {
  70. onUpdated(updatedSyp)
  71. return template('<div>', true)()
  72. },
  73. })
  74. const toggle = ref(true)
  75. const fn1 = () => {}
  76. const fn2 = () => {}
  77. define({
  78. setup() {
  79. const _on_foo = () => (toggle.value ? fn1() : fn2())
  80. return createComponent(Comp, { onFoo: () => _on_foo })
  81. },
  82. }).render()
  83. expect(updatedSyp).toHaveBeenCalledTimes(0)
  84. toggle.value = false
  85. await nextTick()
  86. expect(updatedSyp).toHaveBeenCalledTimes(0)
  87. })
  88. it('component child synchronously updating parent state should trigger parent re-render', async () => {
  89. const { component: Child } = define({
  90. setup() {
  91. const n = inject<Ref<number>>('foo')!
  92. n.value++
  93. const n0 = template('<div></div>')()
  94. renderEffect(() => setElementText(n0, n.value))
  95. return n0
  96. },
  97. })
  98. const { host } = define({
  99. setup() {
  100. const n = ref(0)
  101. provide('foo', n)
  102. const n0 = template('<div></div>')()
  103. renderEffect(() => setElementText(n0, n.value))
  104. return [n0, createComponent(Child)]
  105. },
  106. }).render()
  107. expect(host.innerHTML).toBe('<div>0</div><div>1</div>')
  108. await nextTick()
  109. expect(host.innerHTML).toBe('<div>1</div><div>1</div>')
  110. })
  111. it('component child updating parent state in pre-flush should trigger parent re-render', async () => {
  112. const { component: Child } = define({
  113. props: ['value'],
  114. setup(props: any, { emit }) {
  115. watch(
  116. () => props.value,
  117. val => emit('update', val),
  118. )
  119. const n0 = template('<div></div>')()
  120. renderEffect(() => setElementText(n0, props.value))
  121. return n0
  122. },
  123. })
  124. const outer = ref(0)
  125. const { host } = define({
  126. setup() {
  127. const inner = ref(0)
  128. const n0 = template('<div></div>')()
  129. renderEffect(() => setElementText(n0, inner.value))
  130. const n1 = createComponent(Child, {
  131. value: () => outer.value,
  132. onUpdate: () => (val: number) => (inner.value = val),
  133. })
  134. return [n0, n1]
  135. },
  136. }).render()
  137. expect(host.innerHTML).toBe('<div>0</div><div>0</div>')
  138. outer.value++
  139. await nextTick()
  140. expect(host.innerHTML).toBe('<div>1</div><div>1</div>')
  141. })
  142. it('child only updates once when triggered in multiple ways', async () => {
  143. const a = ref(0)
  144. const calls: string[] = []
  145. const { component: Child } = define({
  146. props: ['count'],
  147. setup(props: any) {
  148. onUpdated(() => calls.push('update child'))
  149. const n = createTextNode()
  150. renderEffect(() => {
  151. setText(n, `${props.count} - ${a.value}`)
  152. })
  153. return n
  154. },
  155. })
  156. const { host } = define({
  157. setup() {
  158. return createComponent(Child, { count: () => a.value })
  159. },
  160. }).render()
  161. expect(host.innerHTML).toBe('0 - 0')
  162. expect(calls).toEqual([])
  163. // This will trigger child rendering directly, as well as via a prop change
  164. a.value++
  165. await nextTick()
  166. expect(host.innerHTML).toBe('1 - 1')
  167. expect(calls).toEqual(['update child'])
  168. })
  169. it(`an earlier update doesn't lead to excessive subsequent updates`, async () => {
  170. const globalCount = ref(0)
  171. const parentCount = ref(0)
  172. const calls: string[] = []
  173. const { component: Child } = define({
  174. props: ['count'],
  175. setup(props: any) {
  176. watch(
  177. () => props.count,
  178. () => {
  179. calls.push('child watcher')
  180. globalCount.value = props.count
  181. },
  182. )
  183. onUpdated(() => calls.push('update child'))
  184. return []
  185. },
  186. })
  187. const { component: Parent } = define({
  188. props: ['count'],
  189. setup(props: any) {
  190. onUpdated(() => calls.push('update parent'))
  191. const n1 = createTextNode()
  192. const n2 = createComponent(Child, { count: () => parentCount.value })
  193. renderEffect(() => {
  194. setText(n1, `${globalCount.value} - ${props.count}`)
  195. })
  196. return [n1, n2]
  197. },
  198. })
  199. const { host } = define({
  200. setup() {
  201. onUpdated(() => calls.push('update root'))
  202. return createComponent(Parent, { count: () => globalCount.value })
  203. },
  204. }).render()
  205. expect(host.innerHTML).toBe(`0 - 0`)
  206. expect(calls).toEqual([])
  207. parentCount.value++
  208. await nextTick()
  209. expect(host.innerHTML).toBe(`1 - 1`)
  210. expect(calls).toEqual(['child watcher', 'update parent'])
  211. })
  212. it('child component props update should not lead to double update', async () => {
  213. const text = ref(0)
  214. const spy = vi.fn()
  215. const { component: Comp } = define({
  216. props: ['text'],
  217. setup(props: any) {
  218. const n1 = template('<h1></h1>')()
  219. renderEffect(() => {
  220. spy()
  221. setElementText(n1, props.text)
  222. })
  223. return n1
  224. },
  225. })
  226. const { host } = define({
  227. setup() {
  228. return createComponent(Comp, { text: () => text.value })
  229. },
  230. }).render()
  231. expect(host.innerHTML).toBe('<h1>0</h1>')
  232. expect(spy).toHaveBeenCalledTimes(1)
  233. text.value++
  234. await nextTick()
  235. expect(host.innerHTML).toBe('<h1>1</h1>')
  236. expect(spy).toHaveBeenCalledTimes(2)
  237. })
  238. it('properly mount child component when using setInsertionState', async () => {
  239. const spy = vi.fn()
  240. const { component: Comp } = define({
  241. setup() {
  242. onMounted(spy)
  243. return template('<h1>hi</h1>')()
  244. },
  245. })
  246. const { host } = define({
  247. setup() {
  248. const n2 = template('<div></div>', true)()
  249. setInsertionState(n2 as any)
  250. createComponent(Comp)
  251. return n2
  252. },
  253. }).render()
  254. expect(host.innerHTML).toBe('<div><h1>hi</h1></div>')
  255. expect(spy).toHaveBeenCalledTimes(1)
  256. })
  257. it('unmount component', async () => {
  258. const { host, app, instance } = define(() => {
  259. const count = ref(0)
  260. const t0 = template('<div></div>')
  261. const n0 = t0()
  262. watchEffect(() => {
  263. setElementText(n0, count.value)
  264. })
  265. renderEffect(() => {})
  266. return n0
  267. }).render()
  268. const i = instance as VaporComponentInstance
  269. // watchEffect + renderEffect + props validation effect
  270. expect(getEffectsCount(i.scope)).toBe(3)
  271. expect(host.innerHTML).toBe('<div>0</div>')
  272. app.unmount()
  273. expect(host.innerHTML).toBe('')
  274. expect(getEffectsCount(i.scope)).toBe(0)
  275. })
  276. test('should mount component only with template in production mode', () => {
  277. __DEV__ = false
  278. const { component: Child } = define({
  279. render() {
  280. return template('<div> HI </div>', true)()
  281. },
  282. })
  283. const { host } = define({
  284. setup() {
  285. return createComponent(Child, null, null, true)
  286. },
  287. }).render()
  288. expect(host.innerHTML).toBe('<div> HI </div>')
  289. __DEV__ = true
  290. })
  291. it('warn if functional vapor component not return a block', () => {
  292. define(() => {
  293. return () => {}
  294. }).render()
  295. expect(
  296. 'Functional vapor component must return a block directly',
  297. ).toHaveBeenWarned()
  298. })
  299. it('warn if setup return a function and no render function', () => {
  300. define({
  301. setup() {
  302. return () => []
  303. },
  304. }).render()
  305. expect(
  306. 'Vapor component setup() returned non-block value, and has no render function',
  307. ).toHaveBeenWarned()
  308. })
  309. })
  310. function getEffectsCount(scope: EffectScope): number {
  311. let n = 0
  312. for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) {
  313. if (dep.dep instanceof ReactiveEffect) {
  314. n++
  315. }
  316. }
  317. return n
  318. }