@tanstack/pacer 0.2.0 → 0.4.0

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.
Files changed (65) hide show
  1. package/dist/cjs/async-debouncer.cjs +78 -45
  2. package/dist/cjs/async-debouncer.cjs.map +1 -1
  3. package/dist/cjs/async-debouncer.d.cts +60 -24
  4. package/dist/cjs/async-queuer.cjs +53 -3
  5. package/dist/cjs/async-queuer.cjs.map +1 -1
  6. package/dist/cjs/async-queuer.d.cts +28 -3
  7. package/dist/cjs/async-rate-limiter.cjs +75 -38
  8. package/dist/cjs/async-rate-limiter.cjs.map +1 -1
  9. package/dist/cjs/async-rate-limiter.d.cts +76 -24
  10. package/dist/cjs/async-throttler.cjs +85 -57
  11. package/dist/cjs/async-throttler.cjs.map +1 -1
  12. package/dist/cjs/async-throttler.d.cts +66 -25
  13. package/dist/cjs/debouncer.cjs +12 -14
  14. package/dist/cjs/debouncer.cjs.map +1 -1
  15. package/dist/cjs/debouncer.d.cts +10 -9
  16. package/dist/cjs/queuer.cjs +51 -1
  17. package/dist/cjs/queuer.cjs.map +1 -1
  18. package/dist/cjs/queuer.d.cts +30 -1
  19. package/dist/cjs/rate-limiter.cjs +19 -9
  20. package/dist/cjs/rate-limiter.cjs.map +1 -1
  21. package/dist/cjs/rate-limiter.d.cts +31 -11
  22. package/dist/cjs/throttler.cjs +29 -36
  23. package/dist/cjs/throttler.cjs.map +1 -1
  24. package/dist/cjs/throttler.d.cts +16 -17
  25. package/dist/cjs/types.d.cts +2 -6
  26. package/dist/cjs/utils.cjs.map +1 -1
  27. package/dist/cjs/utils.d.cts +1 -1
  28. package/dist/esm/async-debouncer.d.ts +60 -24
  29. package/dist/esm/async-debouncer.js +78 -45
  30. package/dist/esm/async-debouncer.js.map +1 -1
  31. package/dist/esm/async-queuer.d.ts +28 -3
  32. package/dist/esm/async-queuer.js +53 -3
  33. package/dist/esm/async-queuer.js.map +1 -1
  34. package/dist/esm/async-rate-limiter.d.ts +76 -24
  35. package/dist/esm/async-rate-limiter.js +75 -38
  36. package/dist/esm/async-rate-limiter.js.map +1 -1
  37. package/dist/esm/async-throttler.d.ts +66 -25
  38. package/dist/esm/async-throttler.js +85 -57
  39. package/dist/esm/async-throttler.js.map +1 -1
  40. package/dist/esm/debouncer.d.ts +10 -9
  41. package/dist/esm/debouncer.js +12 -14
  42. package/dist/esm/debouncer.js.map +1 -1
  43. package/dist/esm/queuer.d.ts +30 -1
  44. package/dist/esm/queuer.js +51 -1
  45. package/dist/esm/queuer.js.map +1 -1
  46. package/dist/esm/rate-limiter.d.ts +31 -11
  47. package/dist/esm/rate-limiter.js +19 -9
  48. package/dist/esm/rate-limiter.js.map +1 -1
  49. package/dist/esm/throttler.d.ts +16 -17
  50. package/dist/esm/throttler.js +29 -36
  51. package/dist/esm/throttler.js.map +1 -1
  52. package/dist/esm/types.d.ts +2 -6
  53. package/dist/esm/utils.d.ts +1 -1
  54. package/dist/esm/utils.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/async-debouncer.ts +130 -81
  57. package/src/async-queuer.ts +93 -8
  58. package/src/async-rate-limiter.ts +141 -67
  59. package/src/async-throttler.ts +152 -94
  60. package/src/debouncer.ts +26 -33
  61. package/src/queuer.ts +92 -4
  62. package/src/rate-limiter.ts +56 -32
  63. package/src/throttler.ts +45 -53
  64. package/src/types.ts +2 -10
  65. package/src/utils.ts +1 -1
@@ -3,23 +3,37 @@ import type { AnyAsyncFunction } from './types'
3
3
  /**
4
4
  * Options for configuring an async throttled function
5
5
  */
6
- export interface AsyncThrottlerOptions<
7
- TFn extends AnyAsyncFunction,
8
- TArgs extends Parameters<TFn>,
9
- > {
6
+ export interface AsyncThrottlerOptions<TFn extends AnyAsyncFunction> {
10
7
  /**
11
8
  * Whether the throttler is enabled. When disabled, maybeExecute will not trigger any executions.
12
9
  * Defaults to true.
13
10
  */
14
11
  enabled?: boolean
12
+ /**
13
+ * Whether to execute the function immediately when called
14
+ * Defaults to true
15
+ */
16
+ leading?: boolean
15
17
  /**
16
18
  * Optional error handler for when the throttled function throws
17
19
  */
18
- onError?: (error: unknown) => void
20
+ onError?: (error: unknown, asyncThrottler: AsyncThrottler<TFn>) => void
19
21
  /**
20
22
  * Optional function to call when the throttled function is executed
21
23
  */
22
- onExecute?: (throttler: AsyncThrottler<TFn, TArgs>) => void
24
+ onSettled?: (asyncThrottler: AsyncThrottler<TFn>) => void
25
+ /**
26
+ * Optional function to call when the throttled function is executed
27
+ */
28
+ onSuccess?: (
29
+ result: ReturnType<TFn>,
30
+ asyncThrottler: AsyncThrottler<TFn>,
31
+ ) => void
32
+ /**
33
+ * Whether to execute the function on the trailing edge of the wait period
34
+ * Defaults to true
35
+ */
36
+ trailing?: boolean
23
37
  /**
24
38
  * Time window in milliseconds during which the function can only be executed once
25
39
  * Defaults to 0ms
@@ -27,10 +41,13 @@ export interface AsyncThrottlerOptions<
27
41
  wait: number
28
42
  }
29
43
 
30
- const defaultOptions: Required<AsyncThrottlerOptions<any, any>> = {
44
+ const defaultOptions: Required<AsyncThrottlerOptions<any>> = {
31
45
  enabled: true,
46
+ leading: true,
32
47
  onError: () => {},
33
- onExecute: () => {},
48
+ onSettled: () => {},
49
+ onSuccess: () => {},
50
+ trailing: true,
34
51
  wait: 0,
35
52
  }
36
53
 
@@ -41,37 +58,41 @@ const defaultOptions: Required<AsyncThrottlerOptions<any, any>> = {
41
58
  * Unlike debouncing which resets the delay timer on each call, throttling ensures the function executes at a
42
59
  * regular interval regardless of how often it's called.
43
60
  *
61
+ * Unlike the non-async Throttler, this async version supports returning values from the throttled function,
62
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
63
+ * instead of setting the result on a state variable from within the throttled function.
64
+ *
44
65
  * This is useful for rate-limiting API calls, handling scroll/resize events, or any scenario where you want to
45
66
  * ensure a maximum execution frequency.
46
67
  *
47
68
  * @example
48
69
  * ```ts
49
70
  * const throttler = new AsyncThrottler(async (value: string) => {
50
- * await saveToAPI(value);
71
+ * const result = await saveToAPI(value);
72
+ * return result; // Return value is preserved
51
73
  * }, { wait: 1000 });
52
74
  *
53
75
  * // Will only execute once per second no matter how often called
54
- * inputElement.addEventListener('input', () => {
55
- * throttler.maybeExecute(inputElement.value);
56
- * });
76
+ * // Returns the API response directly
77
+ * const result = await throttler.maybeExecute(inputElement.value);
57
78
  * ```
58
79
  */
59
- export class AsyncThrottler<
60
- TFn extends AnyAsyncFunction,
61
- TArgs extends Parameters<TFn>,
62
- > {
63
- private _options: Required<AsyncThrottlerOptions<TFn, TArgs>>
80
+ export class AsyncThrottler<TFn extends AnyAsyncFunction> {
81
+ private _options: Required<AsyncThrottlerOptions<TFn>>
64
82
  private _abortController: AbortController | null = null
65
- private _executionCount = 0
83
+ private _errorCount = 0
66
84
  private _isExecuting = false
67
- private _isPending = false
68
- private _lastArgs: TArgs | undefined
85
+ private _lastArgs: Parameters<TFn> | undefined
69
86
  private _lastExecutionTime = 0
87
+ private _lastResult: ReturnType<TFn> | undefined
70
88
  private _nextExecutionTime = 0
89
+ private _settleCount = 0
90
+ private _successCount = 0
91
+ private _timeoutId: NodeJS.Timeout | null = null
71
92
 
72
93
  constructor(
73
94
  private fn: TFn,
74
- initialOptions: AsyncThrottlerOptions<TFn, TArgs>,
95
+ initialOptions: AsyncThrottlerOptions<TFn>,
75
96
  ) {
76
97
  this._options = {
77
98
  ...defaultOptions,
@@ -83,20 +104,19 @@ export class AsyncThrottler<
83
104
  * Updates the throttler options
84
105
  * Returns the new options state
85
106
  */
86
- setOptions(
87
- newOptions: Partial<AsyncThrottlerOptions<TFn, TArgs>>,
88
- ): Required<AsyncThrottlerOptions<TFn, TArgs>> {
89
- this._options = {
90
- ...this._options,
91
- ...newOptions,
107
+ setOptions(newOptions: Partial<AsyncThrottlerOptions<TFn>>): void {
108
+ this._options = { ...this._options, ...newOptions }
109
+
110
+ // End the pending state if the debouncer is disabled
111
+ if (!this._options.enabled) {
112
+ this.cancel()
92
113
  }
93
- return this._options
94
114
  }
95
115
 
96
116
  /**
97
117
  * Returns the current options
98
118
  */
99
- getOptions(): Required<AsyncThrottlerOptions<TFn, TArgs>> {
119
+ getOptions(): Required<AsyncThrottlerOptions<TFn>> {
100
120
  return this._options
101
121
  }
102
122
 
@@ -104,84 +124,82 @@ export class AsyncThrottler<
104
124
  * Attempts to execute the throttled function
105
125
  * If a call is already in progress, it may be blocked or queued depending on the `wait` option
106
126
  */
107
- async maybeExecute(...args: TArgs): Promise<void> {
108
- this._lastArgs = args
109
- if (this._isPending) return
110
- this._isPending = true
127
+ async maybeExecute(
128
+ ...args: Parameters<TFn>
129
+ ): Promise<ReturnType<TFn> | undefined> {
130
+ const now = Date.now()
131
+ const timeSinceLastExecution = now - this._lastExecutionTime
111
132
 
112
- this._abortController = new AbortController()
113
- const signal = this._abortController.signal
133
+ // Handle leading execution
134
+ if (this._options.leading && timeSinceLastExecution >= this._options.wait) {
135
+ await this.executeFunction(...args)
136
+ return this._lastResult
137
+ } else {
138
+ // Store the most recent arguments for potential trailing execution
139
+ this._lastArgs = args
114
140
 
115
- try {
116
- while (this._isExecuting) {
117
- await this.delay(this._options.wait, signal)
118
- }
141
+ return new Promise((resolve) => {
142
+ // Clear any existing timeout to ensure we use the latest arguments
143
+ if (this._timeoutId) {
144
+ clearTimeout(this._timeoutId)
145
+ }
119
146
 
120
- while (Date.now() < this._nextExecutionTime) {
121
- await this.delay(this._nextExecutionTime - Date.now(), signal)
122
- }
147
+ // Set up trailing execution if enabled
148
+ if (this._options.trailing) {
149
+ const _timeSinceLastExecution = this._lastExecutionTime
150
+ ? now - this._lastExecutionTime
151
+ : 0
152
+ const timeoutDuration = this._options.wait - _timeSinceLastExecution
153
+ this._timeoutId = setTimeout(async () => {
154
+ if (this._lastArgs !== undefined) {
155
+ await this.executeFunction(...this._lastArgs)
156
+ }
157
+ resolve(this._lastResult)
158
+ }, timeoutDuration)
159
+ }
160
+ })
161
+ }
162
+ }
123
163
 
124
- this._isPending = false
164
+ private async executeFunction(
165
+ ...args: Parameters<TFn>
166
+ ): Promise<ReturnType<TFn> | undefined> {
167
+ if (!this._options.enabled || this._isExecuting) return undefined
168
+ this._abortController = new AbortController()
169
+ try {
125
170
  this._isExecuting = true
126
-
127
- await this.executeFunction(...this._lastArgs)
171
+ this._lastResult = await this.fn(...args) // EXECUTE!
172
+ this._successCount++
173
+ this._options.onSuccess(this._lastResult!, this)
128
174
  } catch (error) {
129
- if (error instanceof Error && error.name === 'AbortError') {
130
- return // Silent return on cancellation
131
- }
132
- try {
133
- this._options.onError(error)
134
- } catch {
135
- // Ignore errors from error handler
136
- }
175
+ this._errorCount++
176
+ this._options.onError(error, this)
137
177
  } finally {
138
- this._lastExecutionTime = Date.now()
139
- this._nextExecutionTime = this._lastExecutionTime + this._options.wait
140
178
  this._isExecuting = false
179
+ this._settleCount++
141
180
  this._abortController = null
181
+ this._lastExecutionTime = Date.now()
182
+ this._nextExecutionTime = this._lastExecutionTime + this._options.wait
183
+ this._options.onSettled(this)
142
184
  }
143
- }
144
-
145
- private delay(ms: number, signal: AbortSignal): Promise<void> {
146
- return new Promise((resolve, reject) => {
147
- const timeout = setTimeout(resolve, ms)
148
- signal.addEventListener(
149
- 'abort',
150
- () => {
151
- clearTimeout(timeout)
152
- reject(new Error('AbortError'))
153
- },
154
- { once: true },
155
- )
156
- })
157
- }
158
-
159
- private async executeFunction(...args: TArgs): Promise<void> {
160
- if (!this._options.enabled) return
161
- this._executionCount++
162
- await this.fn(...args)
163
- this._options.onExecute(this)
185
+ return this._lastResult
164
186
  }
165
187
 
166
188
  /**
167
- * Cancels any pending execution
189
+ * Cancels any pending execution or aborts any execution in progress
168
190
  */
169
191
  cancel(): void {
192
+ if (this._timeoutId) {
193
+ clearTimeout(this._timeoutId)
194
+ this._timeoutId = null
195
+ }
170
196
  if (this._abortController) {
171
197
  this._abortController.abort()
172
198
  this._abortController = null
173
199
  }
174
- this._isPending = false
175
200
  this._lastArgs = undefined
176
201
  }
177
202
 
178
- /**
179
- * Returns the number of times the function has been executed
180
- */
181
- getExecutionCount(): number {
182
- return this._executionCount
183
- }
184
-
185
203
  /**
186
204
  * Returns the last execution time
187
205
  */
@@ -196,11 +214,46 @@ export class AsyncThrottler<
196
214
  return this._nextExecutionTime
197
215
  }
198
216
 
217
+ /**
218
+ * Returns the last result of the debounced function
219
+ */
220
+ getLastResult(): ReturnType<TFn> | undefined {
221
+ return this._lastResult
222
+ }
223
+
224
+ /**
225
+ * Returns the number of times the function has been executed successfully
226
+ */
227
+ getSuccessCount(): number {
228
+ return this._successCount
229
+ }
230
+
231
+ /**
232
+ * Returns the number of times the function has settled (completed or errored)
233
+ */
234
+ getSettleCount(): number {
235
+ return this._settleCount
236
+ }
237
+
238
+ /**
239
+ * Returns the number of times the function has errored
240
+ */
241
+ getErrorCount(): number {
242
+ return this._errorCount
243
+ }
244
+
199
245
  /**
200
246
  * Returns the current pending state
201
247
  */
202
248
  getIsPending(): boolean {
203
- return this._isPending
249
+ return this._options.enabled && !!this._timeoutId
250
+ }
251
+
252
+ /**
253
+ * Returns the current executing state
254
+ */
255
+ getIsExecuting(): boolean {
256
+ return this._isExecuting
204
257
  }
205
258
  }
206
259
 
@@ -209,21 +262,26 @@ export class AsyncThrottler<
209
262
  * The throttled function will execute at most once per wait period, even if called multiple times.
210
263
  * If called while executing, it will wait until execution completes before scheduling the next call.
211
264
  *
265
+ * Unlike the non-async Throttler, this async version supports returning values from the throttled function,
266
+ * making it ideal for API calls and other async operations where you want the result of the `maybeExecute` call
267
+ * instead of setting the result on a state variable from within the throttled function.
268
+ *
212
269
  * @example
213
270
  * ```ts
214
- * const throttled = asyncThrottle(async () => {
215
- * await someAsyncOperation();
271
+ * const throttled = asyncThrottle(async (value: string) => {
272
+ * const result = await saveToAPI(value);
273
+ * return result; // Return value is preserved
216
274
  * }, { wait: 1000 });
217
275
  *
218
276
  * // This will execute at most once per second
219
- * await throttled();
220
- * await throttled(); // Waits 1 second before executing
277
+ * // Returns the API response directly
278
+ * const result = await throttled(inputElement.value);
221
279
  * ```
222
280
  */
223
- export function asyncThrottle<
224
- TFn extends AnyAsyncFunction,
225
- TArgs extends Parameters<TFn>,
226
- >(fn: TFn, initialOptions: Omit<AsyncThrottlerOptions<TFn, TArgs>, 'enabled'>) {
281
+ export function asyncThrottle<TFn extends AnyAsyncFunction>(
282
+ fn: TFn,
283
+ initialOptions: Omit<AsyncThrottlerOptions<TFn>, 'enabled'>,
284
+ ) {
227
285
  const asyncThrottler = new AsyncThrottler(fn, initialOptions)
228
286
  return asyncThrottler.maybeExecute.bind(asyncThrottler)
229
287
  }
package/src/debouncer.ts CHANGED
@@ -3,10 +3,7 @@ import type { AnyFunction } from './types'
3
3
  /**
4
4
  * Options for configuring a debounced function
5
5
  */
6
- export interface DebouncerOptions<
7
- TFn extends AnyFunction,
8
- TArgs extends Parameters<TFn>,
9
- > {
6
+ export interface DebouncerOptions<TFn extends AnyFunction> {
10
7
  /**
11
8
  * Whether the debouncer is enabled. When disabled, maybeExecute will not trigger any executions.
12
9
  * Defaults to true.
@@ -14,13 +11,14 @@ export interface DebouncerOptions<
14
11
  enabled?: boolean
15
12
  /**
16
13
  * Whether to execute on the leading edge of the timeout.
14
+ * The first call will execute immediately and the rest will wait the delay.
17
15
  * Defaults to false.
18
16
  */
19
17
  leading?: boolean
20
18
  /**
21
19
  * Callback function that is called after the function is executed
22
20
  */
23
- onExecute?: (debouncer: Debouncer<TFn, TArgs>) => void
21
+ onExecute?: (debouncer: Debouncer<TFn>) => void
24
22
  /**
25
23
  * Whether to execute on the trailing edge of the timeout.
26
24
  * Defaults to true.
@@ -33,12 +31,12 @@ export interface DebouncerOptions<
33
31
  wait: number
34
32
  }
35
33
 
36
- const defaultOptions: Required<DebouncerOptions<any, any>> = {
34
+ const defaultOptions: Required<DebouncerOptions<any>> = {
37
35
  enabled: true,
38
36
  leading: false,
37
+ onExecute: () => {},
39
38
  trailing: true,
40
39
  wait: 0,
41
- onExecute: () => {},
42
40
  }
43
41
 
44
42
  /**
@@ -64,16 +62,16 @@ const defaultOptions: Required<DebouncerOptions<any, any>> = {
64
62
  * });
65
63
  * ```
66
64
  */
67
- export class Debouncer<TFn extends AnyFunction, TArgs extends Parameters<TFn>> {
65
+ export class Debouncer<TFn extends AnyFunction> {
68
66
  private _canLeadingExecute = true
69
- private _isPending = false
70
67
  private _executionCount = 0
71
- private _options: Required<DebouncerOptions<TFn, TArgs>>
68
+ private _isPending = false
69
+ private _options: Required<DebouncerOptions<TFn>>
72
70
  private _timeoutId: NodeJS.Timeout | undefined
73
71
 
74
72
  constructor(
75
73
  private fn: TFn,
76
- initialOptions: DebouncerOptions<TFn, TArgs>,
74
+ initialOptions: DebouncerOptions<TFn>,
77
75
  ) {
78
76
  this._options = {
79
77
  ...defaultOptions,
@@ -85,26 +83,19 @@ export class Debouncer<TFn extends AnyFunction, TArgs extends Parameters<TFn>> {
85
83
  * Updates the debouncer options
86
84
  * Returns the new options state
87
85
  */
88
- setOptions(
89
- newOptions: Partial<DebouncerOptions<TFn, TArgs>>,
90
- ): Required<DebouncerOptions<TFn, TArgs>> {
91
- this._options = {
92
- ...this._options,
93
- ...newOptions,
94
- }
86
+ setOptions(newOptions: Partial<DebouncerOptions<TFn>>): void {
87
+ this._options = { ...this._options, ...newOptions }
95
88
 
96
89
  // End the pending state if the debouncer is disabled
97
90
  if (!this._options.enabled) {
98
91
  this._isPending = false
99
92
  }
100
-
101
- return this._options
102
93
  }
103
94
 
104
95
  /**
105
96
  * Returns the current debouncer options
106
97
  */
107
- getOptions(): Required<DebouncerOptions<TFn, TArgs>> {
98
+ getOptions(): Required<DebouncerOptions<TFn>> {
108
99
  return this._options
109
100
  }
110
101
 
@@ -112,35 +103,37 @@ export class Debouncer<TFn extends AnyFunction, TArgs extends Parameters<TFn>> {
112
103
  * Attempts to execute the debounced function
113
104
  * If a call is already in progress, it will be queued
114
105
  */
115
- maybeExecute(...args: TArgs): void {
106
+ maybeExecute(...args: Parameters<TFn>): void {
107
+ let _didLeadingExecute = false
108
+
116
109
  // Handle leading execution
117
110
  if (this._options.leading && this._canLeadingExecute) {
118
- this.executeFunction(...args)
119
111
  this._canLeadingExecute = false
112
+ _didLeadingExecute = true
113
+ this.executeFunction(...args)
120
114
  }
121
115
 
122
- // Start pending state
123
- if (this._options.leading || this._options.trailing) {
116
+ // Start pending state to indicate that the debouncer is waiting for the trailing edge
117
+ if (this._options.trailing) {
124
118
  this._isPending = true
125
119
  }
126
120
 
127
121
  // Clear any existing timeout
128
122
  if (this._timeoutId) clearTimeout(this._timeoutId)
129
123
 
130
- // Set new timeout that will reset canLeadingExecute
124
+ // Set new timeout that will reset canLeadingExecute and execute trailing only if enabled and did not execute leading
131
125
  this._timeoutId = setTimeout(() => {
132
126
  this._canLeadingExecute = true
133
- this._isPending = false
134
- // Execute trailing only if enabled
135
- if (this._options.trailing) {
127
+ if (this._options.trailing && !_didLeadingExecute) {
136
128
  this.executeFunction(...args)
137
129
  }
138
130
  }, this._options.wait)
139
131
  }
140
132
 
141
- private executeFunction(...args: TArgs): void {
142
- if (!this._options.enabled) return
133
+ private executeFunction(...args: Parameters<TFn>): void {
134
+ if (!this._options.enabled) return undefined
143
135
  this.fn(...args) // EXECUTE!
136
+ this._isPending = false
144
137
  this._executionCount++
145
138
  this._options.onExecute(this)
146
139
  }
@@ -193,8 +186,8 @@ export class Debouncer<TFn extends AnyFunction, TArgs extends Parameters<TFn>> {
193
186
  */
194
187
  export function debounce<TFn extends AnyFunction>(
195
188
  fn: TFn,
196
- initialOptions: Omit<DebouncerOptions<TFn, Parameters<TFn>>, 'enabled'>,
197
- ) {
189
+ initialOptions: Omit<DebouncerOptions<TFn>, 'enabled'>,
190
+ ): (...args: Parameters<TFn>) => void {
198
191
  const debouncer = new Debouncer(fn, initialOptions)
199
192
  return debouncer.maybeExecute.bind(debouncer)
200
193
  }