component.spec.ts 13 KB

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