Skip to content

Commit

Permalink
feat(debounce): Support AbortSignal to cancel debounced functions for…
Browse files Browse the repository at this point in the history
… improved cancellation (#45)

* feat: Support AbortSignal to debounce for improved cancellation

* refactor: cancel timeoutId & add strict inequality

* fix: formatting in package.json

* refactor: using optional chaining

* fix: follow the fetch API's option

* docs: modify debounce ko, en docs
  • Loading branch information
Hanna922 authored Jun 13, 2024
1 parent fde86f7 commit a707c06
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 7 deletions.
30 changes: 29 additions & 1 deletion docs/ko/reference/function/debounce.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ debounce된 함수는 또한 대기 중인 실행을 취소하는 `cancel` 메
## 인터페이스

```typescript
function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void };
function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
options?: DebounceOptions
): F & { cancel: () => void };
```

### 파라미터

- `func` (`F`): debounce된 함수를 만들 함수.
- `debounceMs`(`number`): debounce로 지연시킬 밀리초.
- `options` (`DebounceOptions`, optional): 옵션 객체.
- `signal` (`AbortSignal`, optional): debounce된 함수를 취소하기 위한 선택적 `AbortSignal`.

### 결괏값

(`F & { cancel: () => void }`): `cancel` 메서드를 가지고 있는 debounce된 함수.

## 예시

### 기본 사용법

```typescript
const debouncedFunction = debounce(() => {
console.log('실행됨');
Expand All @@ -32,3 +40,23 @@ debouncedFunction();
// 이전 호출이 취소되었으므로, 아무것도 로깅하지 않아요
debouncedFunction.cancel();
```

### AbortSignal 사용법

```typescript
const controller = new AbortController();
const signal = controller.signal;
const debouncedWithSignalFunction = debounce(
() => {
console.log('Function executed');
},
1000,
{ signal }
);

// 1초 안에 다시 호출되지 않으면, '실행됨'을 로깅해요
debouncedWithSignalFunction();

// debounce 함수 호출을 취소해요
controller.abort();
```
32 changes: 30 additions & 2 deletions docs/reference/function/debounce.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ method to cancel any pending execution.
## Signature

```typescript
function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void };
function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
options?: DebounceOptions
): F & { cancel: () => void };
```

### Parameters

- `func` (`F`): The function to debounce.
- `debounceMs`(`number`): The number of milliseconds to delay.
- `debounceMs` (`number`): The number of milliseconds to delay.
- `options` (`DebounceOptions`, optional): An options object.
- `signal` (`AbortSignal`, optional): An optional `AbortSignal` to cancel the debounced function.

### Returns

(`F & { cancel: () => void }`): A new debounced function with a `cancel` method.

## Examples

### Basic Usage

```typescript
const debouncedFunction = debounce(() => {
console.log('Function executed');
Expand All @@ -32,3 +40,23 @@ debouncedFunction();
// Will not log anything as the previous call is canceled
debouncedFunction.cancel();
```

### Using with an AbortSignal

```typescript
const controller = new AbortController();
const signal = controller.signal;
const debouncedWithSignalFunction = debounce(
() => {
console.log('Function executed');
},
1000,
{ signal }
);

// Will log 'Function executed' after 1 second if not called again in that time
debouncedWithSignalFunction();

// Will cancel the debounced function call
controller.abort();
```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,4 @@
"lint": "eslint ./src --ext .ts",
"format": "prettier --write ."
}
}
}
37 changes: 37 additions & 0 deletions src/function/debounce.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,41 @@ describe('debounce', () => {
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('test', 123);
});

it('should cancel the debounced function call if aborted via AbortSignal', async () => {
const func = vi.fn();
const debounceMs = 50;
const controller = new AbortController();
const signal = controller.signal;
const debouncedFunc = debounce(func, debounceMs, { signal });

debouncedFunc();
controller.abort();

await delay(debounceMs);

expect(func).not.toHaveBeenCalled();
});

it('should not add multiple abort event listeners', async () => {
const func = vi.fn();
const debounceMs = 100;
const controller = new AbortController();
const signal = controller.signal;
const addEventListenerSpy = vi.spyOn(signal, 'addEventListener');

const debouncedFunc = debounce(func, debounceMs, { signal });

debouncedFunc();
debouncedFunc();

await new Promise(resolve => setTimeout(resolve, 150));

expect(func).toHaveBeenCalledTimes(1);

const listenerCount = addEventListenerSpy.mock.calls.filter(([event]) => event === 'abort').length;
expect(listenerCount).toBe(1);

addEventListenerSpy.mockRestore();
});
});
42 changes: 39 additions & 3 deletions src/function/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
interface DebounceOptions {
signal?: AbortSignal;
}

/**
* Creates a debounced function that delays invoking the provided function until after `debounceMs` milliseconds
* have elapsed since the last time the debounced function was invoked. The debounced function also has a `cancel`
* method to cancel any pending execution.
*
* @param {F} func - The function to debounce.
* @param {number} debounceMs - The number of milliseconds to delay.
* @param {DebounceOptions} options - The options object.
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the debounced function.
* @returns {F & { cancel: () => void }} A new debounced function with a `cancel` method.
*
* @example
Expand All @@ -17,25 +23,55 @@
*
* // Will not log anything as the previous call is canceled
* debouncedFunction.cancel();
*
* // With AbortSignal
* const controller = new AbortController();
* const signal = controller.signal;
* const debouncedWithSignal = debounce(() => {
* console.log('Function executed');
* }, 1000, { signal });
*
* debouncedWithSignal();
*
* // Will cancel the debounced function call
* controller.abort();
*/
export function debounce<F extends (...args: any[]) => void>(func: F, debounceMs: number): F & { cancel: () => void } {
export function debounce<F extends (...args: any[]) => void>(
func: F,
debounceMs: number,
{ signal }: DebounceOptions = {}
): F & { cancel: () => void } {
let timeoutId: number | NodeJS.Timeout | null = null;

const debounced = function (...args: Parameters<F>) {
if (timeoutId != null) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}

if (signal?.aborted) {
return;
}

timeoutId = setTimeout(() => {
func(...args);
timeoutId = null;
}, debounceMs);
} as F & { cancel: () => void };

const onAbort = function () {
debounced.cancel();
};

debounced.cancel = function () {
if (timeoutId != null) {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}

signal?.removeEventListener('abort', onAbort);
};

signal?.addEventListener('abort', onAbort);

return debounced;
}

0 comments on commit a707c06

Please sign in to comment.