@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.
- package/dist-cjs/index.d.ts +49 -0
- package/dist-cjs/index.js +2 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/throttle.js +113 -69
- package/dist-cjs/lib/throttle.js.map +2 -2
- package/dist-esm/index.d.mts +49 -0
- package/dist-esm/index.mjs +3 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/throttle.mjs +113 -69
- package/dist-esm/lib/throttle.mjs.map +2 -2
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/lib/throttle.test.ts +443 -0
- package/src/lib/throttle.ts +156 -86
|
@@ -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
|
+
})
|