Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions packages/hooks/src/useRequest/__tests__/useThrottlePlugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,122 @@ describe('useThrottlePlugin', () => {

expect(callback).toHaveBeenCalledTimes(2);
});

test('useThrottlePlugin should work with runAsync', () => {
const callback = vi.fn();

act(() => {
hook = setUp(
() => {
callback();
return request({});
},
{
manual: true,
throttleWait: 100,
},
);
});

act(() => {
hook.result.current.runAsync(1);
vi.advanceTimersByTime(50);
hook.result.current.runAsync(2);
vi.advanceTimersByTime(50);
hook.result.current.runAsync(3);
vi.advanceTimersByTime(50);
hook.result.current.runAsync(4);
vi.advanceTimersByTime(40);
});

expect(callback).toHaveBeenCalledTimes(2);
});

test('useThrottlePlugin should respect throttleLeading and throttleTrailing options with runAsync', () => {
const callback = vi.fn();

act(() => {
hook = setUp(
() => {
callback();
return request({});
},
{
manual: true,
throttleWait: 3000,
throttleLeading: true,
throttleTrailing: false,
},
);
Comment on lines +75 to +90
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage only exercises throttleTrailing: false for runAsync. Given the plugin exposes both throttleLeading and throttleTrailing, add a case for throttleTrailing: true (and ideally throttleLeading: false) asserting that the trailing invocation runs and uses the latest call's params; this would catch regressions in trailing behavior.

Copilot uses AI. Check for mistakes.
});

act(() => {
// First call should execute immediately (leading: true)
hook.result.current.runAsync(1);
// These calls should be ignored (within throttle window)
hook.result.current.runAsync(2);
hook.result.current.runAsync(3);
hook.result.current.runAsync(4);
hook.result.current.runAsync(5);
hook.result.current.runAsync(6);
hook.result.current.runAsync(7);

vi.advanceTimersByTime(3000);

// After throttle window, next call should execute
hook.result.current.runAsync(8);
});

// Should only execute twice: first call (leading) and call after throttle window
expect(callback).toHaveBeenCalledTimes(2);
});

test('useThrottlePlugin should resolve all promises when using runAsync', async () => {
let requestCount = 0;

act(() => {
hook = setUp(
() => {
requestCount++;
return request({ id: requestCount });
},
{
manual: true,
throttleWait: 100,
throttleLeading: true,
throttleTrailing: false,
},
);
});

let resolved1 = false;
let resolved2 = false;
let rejected2 = false;

// Make two calls within throttle window
const p1 = hook.result.current.runAsync(1);
const p2 = hook.result.current.runAsync(2);

p1.then(() => {
resolved1 = true;
});
p2.then(() => {
resolved2 = true;
}).catch(() => {
rejected2 = true;
});

// Advance time for throttle and request
await act(async () => {
vi.advanceTimersByTime(100); // throttle wait
vi.advanceTimersByTime(1000); // request wait
await Promise.resolve();
});

// First promise should be resolved
expect(resolved1).toBe(true);
// Second promise should be either resolved or rejected (not hanging)
expect(resolved2 || rejected2).toBe(true);
expect(requestCount).toBe(1); // Only one request should be made
});
});
39 changes: 36 additions & 3 deletions packages/hooks/src/useRequest/src/plugins/useThrottlePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const useThrottlePlugin: Plugin<any, any[]> = (
if (throttleWait) {
const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);

// Track the current promise and when it was created
let currentPromise: Promise<any> | null = null;
let promiseCreatedAt = 0;

throttledRef.current = throttle(
(callback) => {
callback();
Expand All @@ -33,13 +37,42 @@ const useThrottlePlugin: Plugin<any, any[]> = (
// throttle runAsync should be promise
// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
fetchInstance.runAsync = (...args) => {
return new Promise((resolve, reject) => {
const now = Date.now();

// If there's a current promise and it was created within the throttle window,
// return it to share the result
if (currentPromise && now - promiseCreatedAt < throttleWait) {
return currentPromise;
}
Comment on lines 39 to +46
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The currentPromise fast-path returns before calling throttledRef.current, which prevents lodash's throttle from registering subsequent calls. This breaks throttleTrailing: true (no trailing invocation) and throttleLeading: false (trailing uses the first call's args instead of the latest). Consider always invoking the throttled function on every call, while sharing a promise for the current throttle window, and ensure the executed callback uses the latest args for trailing behavior.

Copilot uses AI. Check for mistakes.

// Create a new promise
promiseCreatedAt = now;
currentPromise = new Promise((resolve, reject) => {
throttledRef.current?.(() => {
// Execute the request
_originRunAsync(...args)
.then(resolve)
.catch(reject);
.then((result) => {
resolve(result);
// Clear current promise after a delay to allow trailing calls
setTimeout(() => {
if (currentPromise && Date.now() - promiseCreatedAt >= throttleWait) {
currentPromise = null;
}
}, 0);
Comment on lines +56 to +61
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setTimeout(..., 0) blocks intended to clear currentPromise will almost never clear it because promiseCreatedAt was just set for this window, so Date.now() - promiseCreatedAt >= throttleWait will be false at +0ms. This logic is also duplicated in both then and catch. Either remove the clearing entirely (since the next window overwrites currentPromise), or clear it via a timer aligned to throttleWait / when the throttle window ends.

Copilot uses AI. Check for mistakes.
})
.catch((error) => {
reject(error);
// Clear current promise after a delay to allow trailing calls
setTimeout(() => {
if (currentPromise && Date.now() - promiseCreatedAt >= throttleWait) {
currentPromise = null;
}
}, 0);
});
});
});

return currentPromise;
};

return () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/hooks/src/useRequest/src/useRequestImplement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ function useRequestImplement<TData, TParams extends any[]>(
refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)),
run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)),
runAsync: useMemoizedFn((...args: TParams) => fetchInstance.runAsync(...args)),
mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)),
} as Result<TData, TParams>;
}
Expand Down
Loading