Skip to content

Commit cf53261

Browse files
committed
fix(throttle, debounce): Fix throttle with edges: ['trailing'] behaving like debounce
1 parent e220bb7 commit cf53261

File tree

3 files changed

+84
-23
lines changed

3 files changed

+84
-23
lines changed

src/function/debounce.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ export interface DebounceOptions {
1212
* @default ["trailing"]
1313
*/
1414
edges?: Array<'leading' | 'trailing'>;
15+
16+
/**
17+
* The maximum time the function is allowed to be delayed before it is invoked.
18+
* If provided, the function will be invoked at most once per `maxWait` milliseconds,
19+
* even if the debounced function is called more frequently.
20+
*/
21+
maxWait?: number;
1522
}
1623

1724
export interface DebouncedFunction<F extends (...args: any[]) => void> {
@@ -49,7 +56,9 @@ export interface DebouncedFunction<F extends (...args: any[]) => void> {
4956
* @param {F} func - The function to debounce.
5057
* @param {number} debounceMs - The number of milliseconds to delay.
5158
* @param {DebounceOptions} options - The options object
52-
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
59+
* @param {AbortSignal} [options.signal] - An optional AbortSignal to cancel the debounced function.
60+
* @param {Array<'leading' | 'trailing'>} [options.edges] - An array specifying when the function should be invoked (leading edge, trailing edge, or both).
61+
* @param {number} [options.maxWait] - The maximum time the function is allowed to be delayed before it is invoked.
5362
* @returns A new debounced function with a `cancel` method.
5463
*
5564
* @example
@@ -78,10 +87,11 @@ export interface DebouncedFunction<F extends (...args: any[]) => void> {
7887
export function debounce<F extends (...args: any[]) => void>(
7988
func: F,
8089
debounceMs: number,
81-
{ signal, edges }: DebounceOptions = {}
90+
{ signal, edges, maxWait }: DebounceOptions = {}
8291
): DebouncedFunction<F> {
8392
let pendingThis: any = undefined;
8493
let pendingArgs: Parameters<F> | null = null;
94+
let pendingAt: number | null = null;
8595

8696
const leading = edges != null && edges.includes('leading');
8797
const trailing = edges == null || edges.includes('trailing');
@@ -95,6 +105,8 @@ export function debounce<F extends (...args: any[]) => void>(
95105
};
96106

97107
const onTimerEnd = () => {
108+
pendingAt = null;
109+
98110
if (trailing) {
99111
invoke();
100112
}
@@ -127,6 +139,7 @@ export function debounce<F extends (...args: any[]) => void>(
127139
cancelTimer();
128140
pendingThis = undefined;
129141
pendingArgs = null;
142+
pendingAt = null;
130143
};
131144

132145
const flush = () => {
@@ -144,6 +157,20 @@ export function debounce<F extends (...args: any[]) => void>(
144157

145158
const isFirstCall = timeoutId == null;
146159

160+
if (maxWait != null) {
161+
if (pendingAt === null) {
162+
pendingAt = Date.now();
163+
}
164+
165+
if (Date.now() - pendingAt >= maxWait) {
166+
pendingAt = Date.now();
167+
func.apply(pendingThis, pendingArgs);
168+
cancelTimer();
169+
schedule();
170+
return;
171+
}
172+
}
173+
147174
schedule();
148175

149176
if (leading && isFirstCall) {

src/function/throttle.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,54 @@ describe('throttle', () => {
166166
await delay(throttleMs + 1);
167167
expect(capturedMsg).toBe('hello world');
168168
});
169+
170+
it('should invoke function periodically with trailing edge only', async () => {
171+
const callback = vi.fn();
172+
const throttleMs = 50;
173+
const throttled = throttle(callback, throttleMs, { edges: ['trailing'] });
174+
175+
throttled();
176+
expect(callback).toHaveBeenCalledTimes(0);
177+
178+
await delay(throttleMs + 1);
179+
expect(callback).toHaveBeenCalledTimes(1);
180+
181+
throttled();
182+
expect(callback).toHaveBeenCalledTimes(1);
183+
184+
await delay(throttleMs + 1);
185+
expect(callback).toHaveBeenCalledTimes(2);
186+
});
187+
188+
it('should invoke function periodically during continuous calls with trailing edge only', async () => {
189+
const callback = vi.fn();
190+
const throttleMs = 50;
191+
const throttled = throttle(callback, throttleMs, { edges: ['trailing'] });
192+
193+
const intervalId = setInterval(() => {
194+
throttled();
195+
}, 10);
196+
197+
await delay(throttleMs * 3 + 10);
198+
clearInterval(intervalId);
199+
200+
expect(callback.mock.calls.length).toBeGreaterThanOrEqual(2);
201+
});
202+
203+
it('should invoke function periodically with leading edge only', async () => {
204+
const callback = vi.fn();
205+
const throttleMs = 50;
206+
const throttled = throttle(callback, throttleMs, { edges: ['leading'] });
207+
208+
throttled();
209+
expect(callback).toHaveBeenCalledTimes(1);
210+
211+
await delay(throttleMs / 2);
212+
throttled();
213+
expect(callback).toHaveBeenCalledTimes(1);
214+
215+
await delay(throttleMs / 2 + 1);
216+
throttled();
217+
expect(callback).toHaveBeenCalledTimes(2);
218+
});
169219
});

src/function/throttle.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,9 @@ export function throttle<F extends (...args: any[]) => void>(
5353
throttleMs: number,
5454
{ signal, edges = ['leading', 'trailing'] }: ThrottleOptions = {}
5555
): ThrottledFunction<F> {
56-
let pendingAt: number | null = null;
57-
58-
const debounced = debounce(func, throttleMs, { signal, edges });
59-
60-
const throttled = function (this: any, ...args: Parameters<F>) {
61-
if (pendingAt == null) {
62-
pendingAt = Date.now();
63-
} else {
64-
if (Date.now() - pendingAt >= throttleMs) {
65-
pendingAt = Date.now();
66-
debounced.cancel();
67-
}
68-
}
69-
70-
debounced.apply(this, args);
71-
};
72-
73-
throttled.cancel = debounced.cancel;
74-
throttled.flush = debounced.flush;
75-
76-
return throttled;
56+
return debounce(func, throttleMs, {
57+
signal,
58+
edges,
59+
maxWait: throttleMs,
60+
});
7761
}

0 commit comments

Comments
 (0)