mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge pull request #646 from AutoMaker-Org/fix/excessive-api-polling
fix: excessive api pooling
This commit is contained in:
@@ -13,6 +13,10 @@
|
||||
"./logger": {
|
||||
"types": "./dist/logger.d.ts",
|
||||
"default": "./dist/logger.js"
|
||||
},
|
||||
"./debounce": {
|
||||
"types": "./dist/debounce.d.ts",
|
||||
"default": "./dist/debounce.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
280
libs/utils/src/debounce.ts
Normal file
280
libs/utils/src/debounce.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Debounce and throttle utilities for rate-limiting function calls
|
||||
*/
|
||||
|
||||
/**
|
||||
* Options for the debounce function
|
||||
*/
|
||||
export interface DebounceOptions {
|
||||
/**
|
||||
* If true, call the function immediately on the first invocation (leading edge)
|
||||
* @default false
|
||||
*/
|
||||
leading?: boolean;
|
||||
|
||||
/**
|
||||
* If true, call the function after the delay on the last invocation (trailing edge)
|
||||
* @default true
|
||||
*/
|
||||
trailing?: boolean;
|
||||
|
||||
/**
|
||||
* Maximum time to wait before forcing invocation (useful for continuous events)
|
||||
* If set, the function will be called at most every `maxWait` milliseconds
|
||||
*/
|
||||
maxWait?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type of the debounce function with additional control methods
|
||||
*/
|
||||
export interface DebouncedFunction<T extends (...args: unknown[]) => unknown> {
|
||||
/**
|
||||
* Call the debounced function
|
||||
*/
|
||||
(...args: Parameters<T>): void;
|
||||
|
||||
/**
|
||||
* Cancel any pending invocation
|
||||
*/
|
||||
cancel(): void;
|
||||
|
||||
/**
|
||||
* Immediately invoke any pending function call
|
||||
*/
|
||||
flush(): void;
|
||||
|
||||
/**
|
||||
* Check if there's a pending invocation
|
||||
*/
|
||||
pending(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a debounced version of a function that delays invoking the function
|
||||
* until after `wait` milliseconds have elapsed since the last time the debounced
|
||||
* function was invoked.
|
||||
*
|
||||
* Useful for rate-limiting events like window resize, scroll, or input changes.
|
||||
*
|
||||
* @param fn - The function to debounce
|
||||
* @param wait - The number of milliseconds to delay
|
||||
* @param options - Optional configuration
|
||||
* @returns A debounced version of the function with cancel, flush, and pending methods
|
||||
*
|
||||
* @example
|
||||
* // Basic usage - save input after user stops typing for 300ms
|
||||
* const saveInput = debounce((value: string) => {
|
||||
* api.save(value);
|
||||
* }, 300);
|
||||
*
|
||||
* input.addEventListener('input', (e) => saveInput(e.target.value));
|
||||
*
|
||||
* @example
|
||||
* // With leading edge - execute immediately on first call
|
||||
* const handleClick = debounce(() => {
|
||||
* submitForm();
|
||||
* }, 1000, { leading: true, trailing: false });
|
||||
*
|
||||
* @example
|
||||
* // With maxWait - ensure function runs at least every 5 seconds during continuous input
|
||||
* const autoSave = debounce((content: string) => {
|
||||
* saveToServer(content);
|
||||
* }, 1000, { maxWait: 5000 });
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
wait: number,
|
||||
options: DebounceOptions = {}
|
||||
): DebouncedFunction<T> {
|
||||
const { leading = false, trailing = true, maxWait } = options;
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let maxTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let lastCallTime: number | null = null;
|
||||
let lastInvokeTime = 0;
|
||||
|
||||
// Validate options
|
||||
if (maxWait !== undefined && maxWait < wait) {
|
||||
throw new Error('maxWait must be greater than or equal to wait');
|
||||
}
|
||||
|
||||
function invokeFunc(): void {
|
||||
const args = lastArgs;
|
||||
lastArgs = null;
|
||||
lastInvokeTime = Date.now();
|
||||
|
||||
if (args !== null) {
|
||||
fn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function shouldInvoke(time: number): boolean {
|
||||
const timeSinceLastCall = lastCallTime === null ? 0 : time - lastCallTime;
|
||||
const timeSinceLastInvoke = time - lastInvokeTime;
|
||||
|
||||
// First call, or wait time has passed, or maxWait exceeded
|
||||
return (
|
||||
lastCallTime === null ||
|
||||
timeSinceLastCall >= wait ||
|
||||
timeSinceLastCall < 0 ||
|
||||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait)
|
||||
);
|
||||
}
|
||||
|
||||
function timerExpired(): void {
|
||||
const time = Date.now();
|
||||
|
||||
if (shouldInvoke(time)) {
|
||||
trailingEdge();
|
||||
return;
|
||||
}
|
||||
|
||||
// Restart the timer with remaining time
|
||||
const timeSinceLastCall = lastCallTime === null ? 0 : time - lastCallTime;
|
||||
const timeSinceLastInvoke = time - lastInvokeTime;
|
||||
const timeWaiting = wait - timeSinceLastCall;
|
||||
|
||||
const remainingWait =
|
||||
maxWait !== undefined ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) : timeWaiting;
|
||||
|
||||
timeoutId = setTimeout(timerExpired, remainingWait);
|
||||
}
|
||||
|
||||
function trailingEdge(): void {
|
||||
timeoutId = null;
|
||||
|
||||
if (trailing && lastArgs !== null) {
|
||||
invokeFunc();
|
||||
}
|
||||
|
||||
lastArgs = null;
|
||||
}
|
||||
|
||||
function leadingEdge(time: number): void {
|
||||
lastInvokeTime = time;
|
||||
|
||||
// Start timer for trailing edge
|
||||
timeoutId = setTimeout(timerExpired, wait);
|
||||
|
||||
// Invoke leading edge
|
||||
if (leading) {
|
||||
invokeFunc();
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
if (maxTimeoutId !== null) {
|
||||
clearTimeout(maxTimeoutId);
|
||||
maxTimeoutId = null;
|
||||
}
|
||||
lastArgs = null;
|
||||
lastCallTime = null;
|
||||
lastInvokeTime = 0;
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
if (timeoutId !== null) {
|
||||
invokeFunc();
|
||||
cancel();
|
||||
}
|
||||
}
|
||||
|
||||
function pending(): boolean {
|
||||
return timeoutId !== null;
|
||||
}
|
||||
|
||||
function debounced(...args: Parameters<T>): void {
|
||||
const time = Date.now();
|
||||
const isInvoking = shouldInvoke(time);
|
||||
|
||||
lastArgs = args;
|
||||
lastCallTime = time;
|
||||
|
||||
if (isInvoking) {
|
||||
if (timeoutId === null) {
|
||||
leadingEdge(time);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle maxWait case
|
||||
if (maxWait !== undefined) {
|
||||
timeoutId = setTimeout(timerExpired, wait);
|
||||
invokeFunc();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (timeoutId === null) {
|
||||
timeoutId = setTimeout(timerExpired, wait);
|
||||
}
|
||||
}
|
||||
|
||||
debounced.cancel = cancel;
|
||||
debounced.flush = flush;
|
||||
debounced.pending = pending;
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the throttle function
|
||||
*/
|
||||
export interface ThrottleOptions {
|
||||
/**
|
||||
* If true, call the function on the leading edge
|
||||
* @default true
|
||||
*/
|
||||
leading?: boolean;
|
||||
|
||||
/**
|
||||
* If true, call the function on the trailing edge
|
||||
* @default true
|
||||
*/
|
||||
trailing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled version of a function that only invokes the function
|
||||
* at most once per every `wait` milliseconds.
|
||||
*
|
||||
* Useful for rate-limiting events like scroll or mousemove where you want
|
||||
* regular updates but not on every event.
|
||||
*
|
||||
* @param fn - The function to throttle
|
||||
* @param wait - The number of milliseconds to throttle invocations to
|
||||
* @param options - Optional configuration
|
||||
* @returns A throttled version of the function with cancel, flush, and pending methods
|
||||
*
|
||||
* @example
|
||||
* // Throttle scroll handler to run at most every 100ms
|
||||
* const handleScroll = throttle(() => {
|
||||
* updateScrollPosition();
|
||||
* }, 100);
|
||||
*
|
||||
* window.addEventListener('scroll', handleScroll);
|
||||
*
|
||||
* @example
|
||||
* // Throttle with leading edge only (no trailing call)
|
||||
* const submitOnce = throttle(() => {
|
||||
* submitForm();
|
||||
* }, 1000, { trailing: false });
|
||||
*/
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
wait: number,
|
||||
options: ThrottleOptions = {}
|
||||
): DebouncedFunction<T> {
|
||||
const { leading = true, trailing = true } = options;
|
||||
|
||||
return debounce(fn, wait, {
|
||||
leading,
|
||||
trailing,
|
||||
maxWait: wait,
|
||||
});
|
||||
}
|
||||
@@ -105,3 +105,12 @@ export {
|
||||
type LearningEntry,
|
||||
type SimpleMemoryFile,
|
||||
} from './memory-loader.js';
|
||||
|
||||
// Debounce and throttle utilities
|
||||
export {
|
||||
debounce,
|
||||
throttle,
|
||||
type DebounceOptions,
|
||||
type ThrottleOptions,
|
||||
type DebouncedFunction,
|
||||
} from './debounce.js';
|
||||
|
||||
330
libs/utils/tests/debounce.test.ts
Normal file
330
libs/utils/tests/debounce.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { debounce, throttle } from '../src/debounce.js';
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should delay function execution', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reset timer on subsequent calls', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
debounced(); // Reset timer
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass arguments to the function', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('arg1', 'arg2');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
|
||||
it('should use the latest arguments when called multiple times', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('first');
|
||||
debounced('second');
|
||||
debounced('third');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith('third');
|
||||
});
|
||||
|
||||
describe('leading option', () => {
|
||||
it('should call function immediately when leading is true', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100, { leading: true });
|
||||
|
||||
debounced();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call again until wait time has passed', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100, { leading: true, trailing: false });
|
||||
|
||||
debounced();
|
||||
debounced();
|
||||
debounced();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
debounced();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should call both leading and trailing when both are true', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100, { leading: true, trailing: true });
|
||||
|
||||
debounced('leading');
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenLastCalledWith('leading');
|
||||
|
||||
debounced('trailing');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith('trailing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trailing option', () => {
|
||||
it('should not call on trailing edge when trailing is false', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100, { trailing: false });
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxWait option', () => {
|
||||
it('should invoke function after maxWait even with continuous calls', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100, { maxWait: 200 });
|
||||
|
||||
// Call continuously every 50ms
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
debounced();
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
// After 200ms, maxWait should trigger
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw error if maxWait is less than wait', () => {
|
||||
const fn = vi.fn();
|
||||
expect(() => debounce(fn, 100, { maxWait: 50 })).toThrow(
|
||||
'maxWait must be greater than or equal to wait'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel method', () => {
|
||||
it('should cancel pending invocation', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
debounced.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset state after cancel', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('first');
|
||||
debounced.cancel();
|
||||
debounced('second');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith('second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush method', () => {
|
||||
it('should immediately invoke pending function', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('value');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
debounced.flush();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith('value');
|
||||
});
|
||||
|
||||
it('should not invoke if no pending call', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced.flush();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should cancel timer after flush', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
debounced.flush();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending method', () => {
|
||||
it('should return true when there is a pending invocation', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
expect(debounced.pending()).toBe(false);
|
||||
debounced();
|
||||
expect(debounced.pending()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false after invocation', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(debounced.pending()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after cancel', () => {
|
||||
const fn = vi.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
debounced.cancel();
|
||||
expect(debounced.pending()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should invoke function immediately by default', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not invoke again before wait time', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled();
|
||||
throttled();
|
||||
throttled();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should invoke on trailing edge with latest args', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled('first');
|
||||
expect(fn).toHaveBeenCalledWith('first');
|
||||
|
||||
throttled('second');
|
||||
throttled('third');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(fn).toHaveBeenLastCalledWith('third');
|
||||
});
|
||||
|
||||
it('should respect leading option', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100, { leading: false });
|
||||
|
||||
throttled();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should respect trailing option', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100, { trailing: false });
|
||||
|
||||
throttled('first');
|
||||
throttled('second');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
expect(fn).toHaveBeenCalledWith('first');
|
||||
});
|
||||
|
||||
it('should invoke at regular intervals during continuous calls', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
// Simulate continuous calls every 25ms for 250ms
|
||||
for (let i = 0; i < 10; i++) {
|
||||
throttled(i);
|
||||
vi.advanceTimersByTime(25);
|
||||
}
|
||||
|
||||
// Should be called at: 0ms (leading), 100ms, 200ms
|
||||
// Plus one trailing call after the loop
|
||||
expect(fn.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should have cancel, flush, and pending methods', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
expect(typeof throttled.cancel).toBe('function');
|
||||
expect(typeof throttled.flush).toBe('function');
|
||||
expect(typeof throttled.pending).toBe('function');
|
||||
});
|
||||
|
||||
it('should cancel pending invocation', () => {
|
||||
const fn = vi.fn();
|
||||
const throttled = throttle(fn, 100, { leading: false });
|
||||
|
||||
throttled();
|
||||
throttled.cancel();
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user