@tldraw/utils 4.3.0 → 4.4.0-canary.1e3b436e33e4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,443 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { FpsScheduler, fpsThrottle, throttleToNextFrame } from './throttle'
3
+
4
+ describe('FpsScheduler class', () => {
5
+ let rafCallbacks: Array<FrameRequestCallback> = []
6
+ let rafId = 0
7
+
8
+ beforeEach(() => {
9
+ // Force RAF behavior in tests instead of immediate execution
10
+ // @ts-expect-error - testing flag
11
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
12
+ vi.useFakeTimers()
13
+ vi.clearAllMocks()
14
+
15
+ rafCallbacks = []
16
+ rafId = 0
17
+
18
+ // Mock requestAnimationFrame to work with fake timers
19
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
20
+ const id = ++rafId
21
+ rafCallbacks.push(callback)
22
+ return id
23
+ })
24
+
25
+ vi.stubGlobal('cancelAnimationFrame', (_id: number) => {
26
+ // Simple cancel implementation
27
+ })
28
+ })
29
+
30
+ afterEach(() => {
31
+ vi.unstubAllGlobals()
32
+ vi.useRealTimers()
33
+ })
34
+
35
+ const flushAnimationFrames = () => {
36
+ // May need to flush multiple times as tick() can schedule nested RAF calls
37
+ // But limit iterations to prevent infinite loops
38
+ let iterations = 0
39
+ const maxIterations = 10
40
+ while (rafCallbacks.length > 0 && iterations < maxIterations) {
41
+ const callbacks = [...rafCallbacks]
42
+ rafCallbacks = []
43
+ callbacks.forEach((cb) => cb(performance.now()))
44
+ iterations++
45
+ }
46
+ }
47
+
48
+ describe('FPS throttling', () => {
49
+ it('should throttle to the target FPS', () => {
50
+ const throttle = new FpsScheduler(60) // ~16.67ms per frame, ~15ms with variance
51
+ const fn = vi.fn()
52
+ const throttled = throttle.fpsThrottle(fn)
53
+
54
+ throttled()
55
+ expect(fn).not.toHaveBeenCalled()
56
+
57
+ // Flush the first frame
58
+ flushAnimationFrames()
59
+ expect(fn).toHaveBeenCalledTimes(1)
60
+
61
+ // Call again immediately (within same frame period)
62
+ throttled()
63
+ flushAnimationFrames()
64
+
65
+ // Should not execute again yet (not enough time passed)
66
+ expect(fn).toHaveBeenCalledTimes(1)
67
+
68
+ // Wait for next frame period (16ms+)
69
+ vi.advanceTimersByTime(16)
70
+ throttled()
71
+ flushAnimationFrames()
72
+
73
+ // Now it should execute
74
+ expect(fn).toHaveBeenCalledTimes(2)
75
+ })
76
+
77
+ it('should respect different FPS settings for different instances', () => {
78
+ const fastThrottle = new FpsScheduler(120) // ~8.33ms per frame, ~7.5ms with variance
79
+ const slowThrottle = new FpsScheduler(30) // ~33.33ms per frame, ~30ms with variance
80
+
81
+ const fastFn = vi.fn()
82
+ const slowFn = vi.fn()
83
+
84
+ const throttledFast = fastThrottle.fpsThrottle(fastFn)
85
+ const throttledSlow = slowThrottle.fpsThrottle(slowFn)
86
+
87
+ // Call both - they should both queue and wait for RAF
88
+ throttledFast()
89
+ throttledSlow()
90
+
91
+ // Flush RAF - both will execute on first frame
92
+ flushAnimationFrames()
93
+
94
+ expect(fastFn).toHaveBeenCalledTimes(1)
95
+ expect(slowFn).toHaveBeenCalledTimes(1)
96
+
97
+ // Call again immediately
98
+ throttledFast()
99
+ throttledSlow()
100
+
101
+ // Advance by 8ms - fast can execute again, slow cannot
102
+ vi.advanceTimersByTime(8)
103
+ flushAnimationFrames()
104
+
105
+ expect(fastFn).toHaveBeenCalledTimes(2)
106
+ expect(slowFn).toHaveBeenCalledTimes(1)
107
+
108
+ // Advance by another 25ms (33ms total) - now slow should execute
109
+ vi.advanceTimersByTime(25)
110
+ throttledSlow()
111
+ flushAnimationFrames()
112
+
113
+ expect(slowFn).toHaveBeenCalledTimes(2)
114
+ })
115
+ })
116
+
117
+ describe('throttleToNextFrame', () => {
118
+ it('should execute function on next frame', () => {
119
+ const throttle = new FpsScheduler(120)
120
+ const fn = vi.fn()
121
+
122
+ throttle.throttleToNextFrame(fn)
123
+
124
+ expect(fn).not.toHaveBeenCalled()
125
+
126
+ flushAnimationFrames()
127
+
128
+ expect(fn).toHaveBeenCalledTimes(1)
129
+ })
130
+
131
+ it('should deduplicate same function in queue', () => {
132
+ const throttle = new FpsScheduler(120)
133
+ const fn = vi.fn()
134
+
135
+ throttle.throttleToNextFrame(fn)
136
+ throttle.throttleToNextFrame(fn)
137
+ throttle.throttleToNextFrame(fn)
138
+
139
+ flushAnimationFrames()
140
+
141
+ // Should only execute once despite multiple calls
142
+ expect(fn).toHaveBeenCalledTimes(1)
143
+ })
144
+
145
+ it('should return cancel function that prevents execution', () => {
146
+ const throttle = new FpsScheduler(120)
147
+ const fn = vi.fn()
148
+
149
+ const cancel = throttle.throttleToNextFrame(fn)
150
+ cancel()
151
+
152
+ flushAnimationFrames()
153
+
154
+ expect(fn).not.toHaveBeenCalled()
155
+ })
156
+
157
+ it('should execute multiple different functions', () => {
158
+ const throttle = new FpsScheduler(120)
159
+ const fn1 = vi.fn()
160
+ const fn2 = vi.fn()
161
+ const fn3 = vi.fn()
162
+
163
+ throttle.throttleToNextFrame(fn1)
164
+ throttle.throttleToNextFrame(fn2)
165
+ throttle.throttleToNextFrame(fn3)
166
+
167
+ flushAnimationFrames()
168
+
169
+ expect(fn1).toHaveBeenCalledTimes(1)
170
+ expect(fn2).toHaveBeenCalledTimes(1)
171
+ expect(fn3).toHaveBeenCalledTimes(1)
172
+ })
173
+ })
174
+
175
+ describe('cancel functionality', () => {
176
+ it('should cancel pending throttled function', () => {
177
+ const throttle = new FpsScheduler(120)
178
+ const fn = vi.fn()
179
+ const throttled = throttle.fpsThrottle(fn)
180
+
181
+ throttled()
182
+ expect(fn).not.toHaveBeenCalled()
183
+
184
+ throttled.cancel?.()
185
+
186
+ flushAnimationFrames()
187
+
188
+ expect(fn).not.toHaveBeenCalled()
189
+ })
190
+
191
+ it('should allow function to be called again after cancel', () => {
192
+ const throttle = new FpsScheduler(120)
193
+ const fn = vi.fn()
194
+ const throttled = throttle.fpsThrottle(fn)
195
+
196
+ throttled()
197
+ throttled.cancel?.()
198
+
199
+ flushAnimationFrames()
200
+ expect(fn).not.toHaveBeenCalled()
201
+
202
+ // Call again after cancel - need to advance time to allow re-throttling
203
+ vi.advanceTimersByTime(10)
204
+ throttled()
205
+ flushAnimationFrames()
206
+
207
+ expect(fn).toHaveBeenCalledTimes(1)
208
+ })
209
+ })
210
+
211
+ describe('batching behavior', () => {
212
+ it('should batch multiple calls within same frame window', () => {
213
+ const throttle = new FpsScheduler(60)
214
+ const fn = vi.fn()
215
+ const throttled = throttle.fpsThrottle(fn)
216
+
217
+ // Multiple calls in quick succession
218
+ throttled()
219
+ throttled()
220
+ throttled()
221
+ throttled()
222
+
223
+ flushAnimationFrames()
224
+
225
+ // Should only execute once
226
+ expect(fn).toHaveBeenCalledTimes(1)
227
+ })
228
+
229
+ it('should maintain execution order', () => {
230
+ const throttle = new FpsScheduler(120)
231
+ const results: number[] = []
232
+
233
+ const fn1 = () => results.push(1)
234
+ const fn2 = () => results.push(2)
235
+ const fn3 = () => results.push(3)
236
+
237
+ throttle.throttleToNextFrame(fn1)
238
+ throttle.throttleToNextFrame(fn2)
239
+ throttle.throttleToNextFrame(fn3)
240
+
241
+ flushAnimationFrames()
242
+
243
+ expect(results).toEqual([1, 2, 3])
244
+ })
245
+ })
246
+ })
247
+
248
+ describe('global fpsThrottle function', () => {
249
+ it('should create a throttled function with cancel method', () => {
250
+ const fn = vi.fn()
251
+ const throttled = fpsThrottle(fn)
252
+
253
+ // Should return a function with cancel method
254
+ expect(typeof throttled).toBe('function')
255
+ expect(typeof throttled.cancel).toBe('function')
256
+
257
+ // Calling it should work (actual execution depends on test mode)
258
+ throttled()
259
+
260
+ // Function should be callable without error
261
+ expect(() => throttled()).not.toThrow()
262
+ })
263
+
264
+ it('should delegate to default FpsScheduler instance', () => {
265
+ // This test just verifies the API works, not the RAF behavior
266
+ // (RAF behavior is tested thoroughly in the FpsScheduler class tests)
267
+ const fn1 = vi.fn()
268
+ const fn2 = vi.fn()
269
+
270
+ const throttled1 = fpsThrottle(fn1)
271
+ const throttled2 = fpsThrottle(fn2)
272
+
273
+ expect(throttled1).not.toBe(throttled2)
274
+ expect(typeof throttled1.cancel).toBe('function')
275
+ expect(typeof throttled2.cancel).toBe('function')
276
+ })
277
+ })
278
+
279
+ describe('global throttleToNextFrame function', () => {
280
+ it('should return a cancel function', () => {
281
+ const fn = vi.fn()
282
+
283
+ const cancel = throttleToNextFrame(fn)
284
+
285
+ // Should return a function
286
+ expect(typeof cancel).toBe('function')
287
+
288
+ // Cancel should be callable
289
+ expect(() => cancel()).not.toThrow()
290
+ })
291
+
292
+ it('should delegate to default FpsScheduler instance', () => {
293
+ // This test just verifies the API works, not the RAF behavior
294
+ // (RAF behavior is tested thoroughly in the FpsScheduler class tests)
295
+ const fn1 = vi.fn()
296
+ const fn2 = vi.fn()
297
+
298
+ const cancel1 = throttleToNextFrame(fn1)
299
+ const cancel2 = throttleToNextFrame(fn2)
300
+
301
+ expect(typeof cancel1).toBe('function')
302
+ expect(typeof cancel2).toBe('function')
303
+
304
+ // Both should be callable
305
+ expect(() => cancel1()).not.toThrow()
306
+ expect(() => cancel2()).not.toThrow()
307
+ })
308
+ })
309
+
310
+ describe('real-world scenarios', () => {
311
+ let rafCallbacks: Array<FrameRequestCallback> = []
312
+ let rafId = 0
313
+
314
+ beforeEach(() => {
315
+ // @ts-expect-error - testing flag
316
+ globalThis.__FORCE_RAF_IN_TESTS__ = true
317
+ vi.useFakeTimers()
318
+ vi.clearAllMocks()
319
+
320
+ rafCallbacks = []
321
+ rafId = 0
322
+
323
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
324
+ const id = ++rafId
325
+ rafCallbacks.push(callback)
326
+ return id
327
+ })
328
+
329
+ vi.stubGlobal('cancelAnimationFrame', (_id: number) => {
330
+ // Simple cancel implementation
331
+ })
332
+ })
333
+
334
+ afterEach(() => {
335
+ vi.unstubAllGlobals()
336
+ vi.useRealTimers()
337
+ })
338
+
339
+ const flushAnimationFrames = () => {
340
+ // May need to flush multiple times as tick() can schedule nested RAF calls
341
+ // But limit iterations to prevent infinite loops
342
+ let iterations = 0
343
+ const maxIterations = 10
344
+ while (rafCallbacks.length > 0 && iterations < maxIterations) {
345
+ const callbacks = [...rafCallbacks]
346
+ rafCallbacks = []
347
+ callbacks.forEach((cb) => cb(performance.now()))
348
+ iterations++
349
+ }
350
+ }
351
+
352
+ it('simulates UI throttle (120fps) and sync throttle (30fps) working independently', () => {
353
+ // UI operations at 120fps (~7.5ms per frame with variance)
354
+ const uiThrottle = new FpsScheduler(120)
355
+ const updateUI = vi.fn()
356
+ const throttledUI = uiThrottle.fpsThrottle(updateUI)
357
+
358
+ // Sync operations at 30fps (~30ms per frame with variance)
359
+ const syncThrottle = new FpsScheduler(30)
360
+ const syncData = vi.fn()
361
+ const throttledSync = syncThrottle.fpsThrottle(syncData)
362
+
363
+ // Simulate rapid UI updates and sync requests
364
+ throttledUI()
365
+ throttledUI()
366
+ throttledSync()
367
+ throttledSync()
368
+
369
+ // First RAF flush - both execute on first frame
370
+ flushAnimationFrames()
371
+
372
+ expect(updateUI).toHaveBeenCalledTimes(1)
373
+ expect(syncData).toHaveBeenCalledTimes(1)
374
+
375
+ // More rapid calls immediately
376
+ throttledUI()
377
+ throttledSync()
378
+ flushAnimationFrames()
379
+
380
+ // Neither should execute yet (not enough time passed)
381
+ expect(updateUI).toHaveBeenCalledTimes(1)
382
+ expect(syncData).toHaveBeenCalledTimes(1)
383
+
384
+ // Advance 8ms - UI can execute again, sync still waiting
385
+ vi.advanceTimersByTime(8)
386
+ throttledUI()
387
+ flushAnimationFrames()
388
+
389
+ expect(updateUI).toHaveBeenCalledTimes(2)
390
+ expect(syncData).toHaveBeenCalledTimes(1)
391
+
392
+ // Advance another 25ms (33ms total) - now sync should execute
393
+ vi.advanceTimersByTime(25)
394
+ throttledSync()
395
+ flushAnimationFrames()
396
+
397
+ expect(syncData).toHaveBeenCalledTimes(2)
398
+ })
399
+
400
+ it('simulates switching between solo (1fps) and collaborative mode (30fps)', () => {
401
+ const throttle = new FpsScheduler(30) // Start at collaborative mode (~30ms per frame)
402
+ const syncFn = vi.fn()
403
+ const throttled = throttle.fpsThrottle(syncFn)
404
+
405
+ // Call in collaborative mode
406
+ throttled()
407
+ flushAnimationFrames()
408
+
409
+ expect(syncFn).toHaveBeenCalledTimes(1)
410
+
411
+ // Call again after enough time
412
+ vi.advanceTimersByTime(31)
413
+ throttled()
414
+ flushAnimationFrames()
415
+
416
+ expect(syncFn).toHaveBeenCalledTimes(2)
417
+
418
+ // Note: In real implementation, you'd recreate the throttle with new FPS
419
+ // This test shows that each instance maintains its own FPS setting
420
+ const soloThrottle = new FpsScheduler(1) // ~900ms per frame with variance
421
+ const soloSyncFn = vi.fn()
422
+ const soloThrottled = soloThrottle.fpsThrottle(soloSyncFn)
423
+
424
+ soloThrottled()
425
+ flushAnimationFrames()
426
+
427
+ expect(soloSyncFn).toHaveBeenCalledTimes(1)
428
+
429
+ // Call again too soon
430
+ soloThrottled()
431
+ flushAnimationFrames()
432
+
433
+ // Should not execute yet
434
+ expect(soloSyncFn).toHaveBeenCalledTimes(1)
435
+
436
+ // Advance enough time for 1fps
437
+ vi.advanceTimersByTime(1000)
438
+ soloThrottled()
439
+ flushAnimationFrames()
440
+
441
+ expect(soloSyncFn).toHaveBeenCalledTimes(2)
442
+ })
443
+ })