Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(delay): Support AbortSignal to delay for improved cancellation #52

Merged
merged 6 commits into from
Jun 15, 2024
Merged
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
24 changes: 22 additions & 2 deletions docs/ko/reference/promise/delay.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@

코드의 실행을 주어진 밀리세컨드만큼 지연시켜요.

이 함수는 특정한 시간 이후에 Resolve되는 Promise를 반환해요. async/await 함수를 사용하는 경우에 함수의 실행을 잠깐 일시정지시킬 수 있어요.
이 함수는 특정한 시간 이후에 Resolve되는 Promise를 반환해요. async/await 함수를 사용하는 경우에 함수의 실행을 잠깐 일시정지시킬 수 있어요. 또한, 선택 옵션으로 지연을 취소할 수 있는 AbortSignal을 지원해요.

## 인터페이스

```typescript
function delay(ms: number): Promise<void>;
function delay(ms: number, options?: DelayOptions): Promise<void>;
```

### 파라미터

- `ms` (`number`): 코드 실행을 지연시킬 밀리세컨드.
- `options` (`DelayOptions`, optional): 옵션 객체.
- `signal` (`AbortSignal`, optional): 지연을 취소하기 위한 선택적 `AbortSignal`.

### 반환 값

(`Promise<void>`): 정해진 지연시간 이후에 Resolve될 Promise.

## 예시

### 기본 사용법

```typescript
async function foo() {
console.log('시작');
Expand All @@ -29,3 +33,19 @@ async function foo() {

foo();
```

### AbortSignal 사용법

```typescript
async function foo() {
const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 50); // 50ms 후 지연을 취소
try {
await delay(1000, { signal });
} catch (error) {
console.log(error); // 'The operation was aborted' 로깅
}
}
```
23 changes: 22 additions & 1 deletion docs/reference/promise/delay.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,28 @@ Delays the execution of code for a specified number of milliseconds.

This function returns a Promise that resolves after the specified delay, allowing you to use it
with async/await to pause execution.
It also supports an optional AbortSignal to cancel the delay.

## Signature

```typescript
function delay(ms: number): Promise<void>;
function delay(ms: number, options?: DelayOptions): Promise<void>;
```

### Parameters

- `ms` (`number`): The number of milliseconds to delay.
- `options` (`DelayOptions`, optional): An options object.
- `signal` (`AbortSignal`, optional): An optional `AbortSignal` to cancel the delay.

### Returns

(`Promise<void>`): A Promise that resolves after the specified delay.

## Examples

### Basic Usage

```typescript
async function foo() {
console.log('Start');
Expand All @@ -30,3 +35,19 @@ async function foo() {

foo();
```

### Using with an AbortSignal

```typescript
async function foo() {
const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 50); // Will cancel the delay after 50ms
try {
await delay(1000, { signal });
} catch (error) {
console.log(error); // Will log 'The operation was aborted'
}
}
```
6 changes: 6 additions & 0 deletions src/error/AbortError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class AbortError extends Error {
constructor(message = 'The operation was aborted') {
super(message);
this.name = 'AbortError';
}
}
1 change: 1 addition & 0 deletions src/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AbortError } from './AbortError';
2 changes: 1 addition & 1 deletion src/function/debounce.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('debounce', () => {
await delay(debounceMs);

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

it('should not add multiple abort event listeners', async () => {
const func = vi.fn();
Expand Down
4 changes: 1 addition & 3 deletions src/function/debounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,9 @@ export function debounce<F extends (...args: any[]) => void>(
clearTimeout(timeoutId);
timeoutId = null;
}

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

signal?.addEventListener('abort', onAbort);
signal?.addEventListener('abort', onAbort, { once: true });
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍


return debounced;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './array';
export * from './error';
export * from './function';
export * from './math';
export * from './object';
Expand Down
38 changes: 37 additions & 1 deletion src/promise/delay.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { performance } from 'node:perf_hooks';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { delay } from './delay';

describe('delay', () => {
Expand All @@ -10,4 +10,40 @@ describe('delay', () => {

expect(end - start).greaterThanOrEqual(99);
});

it('should cancel the delay if aborted via AbortSignal', async () => {
const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 50);

await expect(delay(100, { signal })).rejects.toThrow('The operation was aborted');
});

it('should not call the delay if it is already aborted by AbortSignal', async () => {
const controller = new AbortController();
const { signal } = controller;
const spy = vi.spyOn(global, 'setTimeout');

controller.abort();

await expect(delay(100, { signal })).rejects.toThrow('The operation was aborted');

expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});

it('should clear timeout when aborted by AbortSignal', async () => {
const controller = new AbortController();
const { signal } = controller;
const spy = vi.spyOn(global, 'clearTimeout');
const promise = delay(100, { signal });

controller.abort();

await expect(promise).rejects.toThrow('The operation was aborted');

expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
41 changes: 38 additions & 3 deletions src/promise/delay.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { AbortError } from '../error/AbortError';

interface DelayOptions {
signal?: AbortSignal;
}

/**
* Delays the execution of code for a specified number of milliseconds.
*
* This function returns a Promise that resolves after the specified delay, allowing you to use it
* with async/await to pause execution.
*
* @param {number} ms - The number of milliseconds to delay.
* @param {DelayOptions} options - The options object.
* @param {AbortSignal} options.signal - An optional AbortSignal to cancel the delay.
* @returns {Promise<void>} A Promise that resolves after the specified delay.
*
* @example
Expand All @@ -15,9 +23,36 @@
* }
*
* foo();
*
* // With AbortSignal
* const controller = new AbortController();
* const { signal } = controller;
*
* setTimeout(() => controller.abort(), 50); // Will cancel the delay after 50ms
* try {
* await delay(100, { signal });
* } catch (error) {
* console.error(error); // Will log 'AbortError'
* }
* }
*/
export function delay(ms: number): Promise<void> {
return new Promise(resolve => {
setTimeout(resolve, ms);
export function delay(ms: number, { signal }: DelayOptions = {}): Promise<void> {
return new Promise((resolve, reject) => {
const abortError = () => {
reject(new AbortError());
};

const abortHandler = () => {
clearTimeout(timeoutId);
abortError();
};

if (signal?.aborted) {
return abortError();
}

const timeoutId = setTimeout(resolve, ms);

signal?.addEventListener('abort', abortHandler, { once: true });
});
}