watch.spec.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import {
  2. EffectScope,
  3. type Ref,
  4. WatchErrorCodes,
  5. type WatchOptions,
  6. type WatchScheduler,
  7. computed,
  8. onWatcherCleanup,
  9. ref,
  10. watch,
  11. } from '../src'
  12. const queue: (() => void)[] = []
  13. // a simple scheduler for testing purposes
  14. let isFlushPending = false
  15. const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise<any>
  16. const nextTick = (fn?: () => any) =>
  17. fn ? resolvedPromise.then(fn) : resolvedPromise
  18. const scheduler: WatchScheduler = (job, isFirstRun) => {
  19. if (isFirstRun) {
  20. job()
  21. } else {
  22. queue.push(job)
  23. flushJobs()
  24. }
  25. }
  26. const flushJobs = () => {
  27. if (isFlushPending) return
  28. isFlushPending = true
  29. resolvedPromise.then(() => {
  30. queue.forEach(job => job())
  31. queue.length = 0
  32. isFlushPending = false
  33. })
  34. }
  35. describe('watch', () => {
  36. test('effect', () => {
  37. let dummy: any
  38. const source = ref(0)
  39. watch(() => {
  40. dummy = source.value
  41. })
  42. expect(dummy).toBe(0)
  43. source.value++
  44. expect(dummy).toBe(1)
  45. })
  46. test('with callback', () => {
  47. let dummy: any
  48. const source = ref(0)
  49. watch(source, () => {
  50. dummy = source.value
  51. })
  52. expect(dummy).toBe(undefined)
  53. source.value++
  54. expect(dummy).toBe(1)
  55. })
  56. test('call option with error handling', () => {
  57. const onError = vi.fn()
  58. const call: WatchOptions['call'] = function call(fn, type, args) {
  59. if (Array.isArray(fn)) {
  60. fn.forEach(f => call(f, type, args))
  61. return
  62. }
  63. try {
  64. fn.apply(null, args)
  65. } catch (e) {
  66. onError(e, type)
  67. }
  68. }
  69. watch(
  70. () => {
  71. throw 'oops in effect'
  72. },
  73. null,
  74. { call },
  75. )
  76. const source = ref(0)
  77. const effect = watch(
  78. source,
  79. () => {
  80. onWatcherCleanup(() => {
  81. throw 'oops in cleanup'
  82. })
  83. throw 'oops in watch'
  84. },
  85. { call },
  86. )
  87. expect(onError.mock.calls.length).toBe(1)
  88. expect(onError.mock.calls[0]).toMatchObject([
  89. 'oops in effect',
  90. WatchErrorCodes.WATCH_CALLBACK,
  91. ])
  92. source.value++
  93. expect(onError.mock.calls.length).toBe(2)
  94. expect(onError.mock.calls[1]).toMatchObject([
  95. 'oops in watch',
  96. WatchErrorCodes.WATCH_CALLBACK,
  97. ])
  98. effect!.stop()
  99. source.value++
  100. expect(onError.mock.calls.length).toBe(3)
  101. expect(onError.mock.calls[2]).toMatchObject([
  102. 'oops in cleanup',
  103. WatchErrorCodes.WATCH_CLEANUP,
  104. ])
  105. })
  106. test('watch with onWatcherCleanup', async () => {
  107. let dummy = 0
  108. let source: Ref<number>
  109. const scope = new EffectScope()
  110. scope.run(() => {
  111. source = ref(0)
  112. watch(onCleanup => {
  113. source.value
  114. onCleanup(() => (dummy += 2))
  115. onWatcherCleanup(() => (dummy += 3))
  116. onWatcherCleanup(() => (dummy += 5))
  117. })
  118. })
  119. expect(dummy).toBe(0)
  120. scope.run(() => {
  121. source.value++
  122. })
  123. expect(dummy).toBe(10)
  124. scope.run(() => {
  125. source.value++
  126. })
  127. expect(dummy).toBe(20)
  128. scope.stop()
  129. expect(dummy).toBe(30)
  130. })
  131. test('nested calls to baseWatch and onWatcherCleanup', async () => {
  132. let calls: string[] = []
  133. let source: Ref<number>
  134. let copyist: Ref<number>
  135. const scope = new EffectScope()
  136. scope.run(() => {
  137. source = ref(0)
  138. copyist = ref(0)
  139. // sync by default
  140. watch(
  141. () => {
  142. const current = (copyist.value = source.value)
  143. onWatcherCleanup(() => calls.push(`sync ${current}`))
  144. },
  145. null,
  146. {},
  147. )
  148. // with scheduler
  149. watch(
  150. () => {
  151. const current = copyist.value
  152. onWatcherCleanup(() => calls.push(`post ${current}`))
  153. },
  154. null,
  155. { scheduler },
  156. )
  157. })
  158. await nextTick()
  159. expect(calls).toEqual([])
  160. scope.run(() => source.value++)
  161. expect(calls).toEqual(['sync 0'])
  162. await nextTick()
  163. expect(calls).toEqual(['sync 0', 'post 0'])
  164. calls.length = 0
  165. scope.run(() => source.value++)
  166. expect(calls).toEqual(['sync 1'])
  167. await nextTick()
  168. expect(calls).toEqual(['sync 1', 'post 1'])
  169. calls.length = 0
  170. scope.stop()
  171. expect(calls).toEqual(['sync 2', 'post 2'])
  172. })
  173. test('once option should be ignored by simple watch', async () => {
  174. let dummy: any
  175. const source = ref(0)
  176. watch(
  177. () => {
  178. dummy = source.value
  179. },
  180. null,
  181. { once: true },
  182. )
  183. expect(dummy).toBe(0)
  184. source.value++
  185. expect(dummy).toBe(1)
  186. })
  187. // #12033
  188. test('recursive sync watcher on computed', () => {
  189. const r = ref(0)
  190. const c = computed(() => r.value)
  191. watch(c, v => {
  192. if (v > 1) {
  193. r.value--
  194. }
  195. })
  196. expect(r.value).toBe(0)
  197. expect(c.value).toBe(0)
  198. r.value = 10
  199. expect(r.value).toBe(1)
  200. expect(c.value).toBe(1)
  201. })
  202. // edge case where a nested endBatch() causes an effect to be batched in a
  203. // nested batch loop with its .next mutated, causing the outer loop to end
  204. // early
  205. test('nested batch edge case', () => {
  206. // useClamp from VueUse
  207. const clamp = (n: number, min: number, max: number) =>
  208. Math.min(max, Math.max(min, n))
  209. function useClamp(src: Ref<number>, min: number, max: number) {
  210. return computed({
  211. get() {
  212. return (src.value = clamp(src.value, min, max))
  213. },
  214. set(val) {
  215. src.value = clamp(val, min, max)
  216. },
  217. })
  218. }
  219. const src = ref(1)
  220. const clamped = useClamp(src, 1, 5)
  221. watch(src, val => (clamped.value = val))
  222. const spy = vi.fn()
  223. watch(clamped, spy)
  224. src.value = 2
  225. expect(spy).toHaveBeenCalledTimes(1)
  226. src.value = 10
  227. expect(spy).toHaveBeenCalledTimes(2)
  228. })
  229. test('should ensure correct execution order in batch processing', () => {
  230. const dummy: number[] = []
  231. const n1 = ref(0)
  232. const n2 = ref(0)
  233. const sum = computed(() => n1.value + n2.value)
  234. watch(n1, () => {
  235. dummy.push(1)
  236. n2.value++
  237. })
  238. watch(sum, () => dummy.push(2))
  239. watch(n1, () => dummy.push(3))
  240. n1.value++
  241. expect(dummy).toEqual([1, 2, 3])
  242. })
  243. test('watch with immediate reset and sync flush', () => {
  244. const value = ref(false)
  245. watch(value, () => {
  246. value.value = false
  247. })
  248. value.value = true
  249. value.value = true
  250. expect(value.value).toBe(false)
  251. })
  252. })