Skip to content

Commit 77ca1d5

Browse files
authored
✨ feat: [URH-14] useTimer 머지 (#33)
[URH-14] useTimer 신규
2 parents 9b857a1 + e0ee71a commit 77ca1d5

File tree

6 files changed

+352
-11
lines changed

6 files changed

+352
-11
lines changed

src/hooks/useAsyncTasks.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
PromiseCircularityError,
77
} from 'async-wave';
88

9+
import { delayExecution } from '../utils';
10+
911
type Task<R> = R | ((input: R) => R | Promise<R> | void | Promise<void>);
1012

1113
type HookOptionProps = {
@@ -95,14 +97,14 @@ const generateTaskHandlers = <R>(
9597

9698
dispatch({ type: ActionType.LOADING });
9799
options?.initialLazyDelay &&
98-
(await delayExecution(options.initialLazyDelay));
100+
(await delayExecution(options.initialLazyDelay).start());
99101
onBefore?.();
100102
},
101103
async onSuccess(payload: R) {
102104
if (!isMountedRef.current) return;
103105

104106
options?.successLazyDelay &&
105-
(await delayExecution(options.successLazyDelay));
107+
(await delayExecution(options.successLazyDelay).start());
106108
dispatch({ type: ActionType.SUCCESS, payload });
107109
onSuccess?.(payload);
108110
},
@@ -169,13 +171,4 @@ function reducer<R>(state: TaskState<R>, action: TaskAction<R>): TaskState<R> {
169171
}
170172
}
171173

172-
/**
173-
* delayExecution 함수
174-
* @param {number} ms - 지연 시간 (밀리초)
175-
* @returns {Promise<void>} 주어진 시간만큼 지연된 후 resolve되는 프로미스
176-
*/
177-
const delayExecution = (ms: number) => {
178-
return new Promise((resolve) => setTimeout(resolve, ms));
179-
};
180-
181174
export default useAsyncTasks;

src/hooks/useTimer.test.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
renderHook,
3+
act,
4+
render,
5+
screen,
6+
fireEvent,
7+
} from '@testing-library/react';
8+
import { useState } from 'react';
9+
import '@testing-library/jest-dom';
10+
11+
import useTimer from './useTimer';
12+
13+
let callback: jest.Mock;
14+
15+
beforeEach(() => {
16+
jest.useFakeTimers();
17+
callback = jest.fn();
18+
});
19+
20+
afterEach(() => {
21+
jest.clearAllTimers();
22+
jest.restoreAllMocks();
23+
});
24+
25+
afterAll(() => {
26+
jest.useRealTimers();
27+
});
28+
29+
const delay = 3000;
30+
31+
describe('useTimer hook spec', () => {
32+
test('start 메서드를 호출하면 타이머가 시작되고, 지정된 시간이 경과한 후 콜백이 호출되어야 한다.', async () => {
33+
const spySetTimeout = jest.spyOn(globalThis, 'setTimeout');
34+
const { result } = renderHook(() => useTimer(callback, delay));
35+
36+
act(() => {
37+
result.current.start();
38+
});
39+
jest.advanceTimersByTime(delay);
40+
// 비동기 작업을 포함하는 테스트에서 모든 작업이 완료될 때까지 정확히 기다리도록 보장
41+
await act(async () => {});
42+
43+
expect(spySetTimeout).toHaveBeenCalledTimes(1);
44+
expect(callback).toHaveBeenCalledTimes(1);
45+
});
46+
47+
test('cancel 메서드를 호출하면 타이머가 중단되고, 콜백이 호출되지 않아야 한다.', async () => {
48+
const spyClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
49+
const { result } = renderHook(() => useTimer(callback, delay));
50+
51+
act(() => {
52+
result.current.start();
53+
result.current.cancel();
54+
});
55+
await act(async () => {});
56+
57+
expect(callback).toHaveBeenCalledTimes(0);
58+
expect(spyClearTimeout).toHaveBeenCalledTimes(1);
59+
});
60+
61+
test('타이머가 완료된 후에는 최신 콜백만을 실행해야 한다.', async () => {
62+
const initialCallback = jest.fn();
63+
const updatedCallback = jest.fn();
64+
const { result, rerender } = renderHook(
65+
({ callback }) => useTimer(callback, delay),
66+
{
67+
initialProps: { callback: initialCallback },
68+
}
69+
);
70+
71+
rerender({ callback: updatedCallback });
72+
act(() => {
73+
result.current.start();
74+
});
75+
jest.advanceTimersByTime(delay);
76+
await act(async () => {});
77+
78+
expect(initialCallback).not.toHaveBeenCalled();
79+
expect(updatedCallback).toHaveBeenCalledTimes(1);
80+
});
81+
82+
test('언마운트할 때 타이머가 정리되어야 하며, 따라서 콜백이 호출되지 않아야 한다.', async () => {
83+
const spySetTimeout = jest.spyOn(globalThis, 'setTimeout');
84+
const spyClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
85+
const { result, unmount } = renderHook(() => useTimer(callback, delay));
86+
87+
act(() => {
88+
result.current.start();
89+
});
90+
unmount();
91+
jest.advanceTimersByTime(delay);
92+
await act(async () => {});
93+
94+
expect(callback).not.toHaveBeenCalled();
95+
expect(spySetTimeout).toHaveBeenCalledTimes(1);
96+
expect(spyClearTimeout).toHaveBeenCalledTimes(1);
97+
});
98+
});
99+
100+
describe('useTimer를 사용한 컴포넌트 테스트', () => {
101+
const TestComponent = () => {
102+
const [count, setCount] = useState(0);
103+
const { start, cancel } = useTimer(() => {
104+
setCount(1234);
105+
}, delay);
106+
107+
return (
108+
<div>
109+
<p aria-label="count-display">{count}</p>
110+
<button onClick={start}>start</button>
111+
<button onClick={cancel}>cancel</button>
112+
</div>
113+
);
114+
};
115+
116+
test('타이머가 진행된 이후 값이 반영되어야 한다.', async () => {
117+
const spySetTimeout = jest.spyOn(globalThis, 'setTimeout');
118+
119+
render(<TestComponent />);
120+
121+
fireEvent.click(screen.getByText('start'));
122+
jest.advanceTimersByTime(delay);
123+
await act(async () => {});
124+
125+
expect(screen.getByLabelText('count-display')).toHaveTextContent('1234');
126+
expect(spySetTimeout).toHaveBeenCalledTimes(1);
127+
});
128+
129+
test('타이머가 취소된 경우 값이 변경되지 말아야 한다.', async () => {
130+
const spyClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
131+
132+
render(<TestComponent />);
133+
134+
fireEvent.click(screen.getByText('start'));
135+
fireEvent.click(screen.getByText('cancel'));
136+
jest.advanceTimersByTime(delay);
137+
await act(async () => {});
138+
139+
expect(screen.getByLabelText('count-display')).toHaveTextContent('0');
140+
expect(spyClearTimeout).toHaveBeenCalledTimes(1);
141+
});
142+
});

src/hooks/useTimer.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
import { type CancelToken, delayExecution } from '../utils';
4+
import useUnmountEffect from './useUnmountEffect';
5+
6+
type Callback = () => void;
7+
8+
/**
9+
* 일정 시간(ms) 후에 지정된 콜백 함수를 실행하는 타이머 훅.
10+
*
11+
* @param {function} callback 타이머가 완료된 후 실행할 콜백 함수.
12+
* @param {number} ms 지연 시간(밀리초). 양의 정수로 지정.
13+
*
14+
* @returns {object}
15+
* - `start`: 타이머를 시작하는 함수.
16+
* - `cancel`: 현재 활성화된 타이머를 취소하는 함수.
17+
*
18+
* @description
19+
* - 이 훅은 주어진 시간(ms)이 지난 후 콜백 함수를 실행하는 타이머를 제공합니다.
20+
* - `start` 함수를 호출하면 타이머가 시작되며, 지정된 시간이 지나면 콜백 함수가 호출됩니다.
21+
* - `cancel` 함수를 호출하면 현재 활성화된 타이머를 취소할 수 있습니다.
22+
* - 콜백 함수가 변경될 때마다 참조를 업데이트합니다.
23+
* - 컴포넌트가 언마운트될 때 타이머를 정리합니다.
24+
*/
25+
const useTimer = (callback: Callback, ms: number) => {
26+
const callbackRef = useRef<Callback>(callback);
27+
const timerRef = useRef<ReturnType<typeof delayExecution> | null>(null);
28+
const cancelTokenRef = useRef<CancelToken>({ isCancelled: false });
29+
30+
const clearActiveTimer = () => {
31+
if (timerRef.current) {
32+
cancelTokenRef.current.isCancelled = true;
33+
34+
timerRef.current.clear();
35+
}
36+
};
37+
38+
const startHandler = useCallback(() => {
39+
clearActiveTimer(); // 기존 타이머 취소
40+
41+
(async () => {
42+
cancelTokenRef.current = { isCancelled: false };
43+
timerRef.current = delayExecution(ms);
44+
45+
await timerRef.current.start(cancelTokenRef.current);
46+
callbackRef.current();
47+
})();
48+
}, [ms]);
49+
50+
const cancelHandler = useCallback(() => {
51+
clearActiveTimer();
52+
}, []);
53+
54+
useEffect(() => {
55+
callbackRef.current = callback;
56+
}, [callback]);
57+
58+
useUnmountEffect(clearActiveTimer);
59+
60+
return { start: startHandler, cancel: cancelHandler };
61+
};
62+
63+
export default useTimer;

src/utils/delayExecution.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { delayExecution, type CancelToken } from './delayExecution';
2+
3+
describe('delayExecution', () => {
4+
beforeEach(() => {
5+
jest.useFakeTimers();
6+
});
7+
8+
afterEach(() => {
9+
jest.clearAllTimers();
10+
jest.restoreAllMocks();
11+
});
12+
13+
const delay = 3000;
14+
15+
test('명시된 시간 후 "started"로 이행해야 한다.', async () => {
16+
const spySetTimeout = jest.spyOn(globalThis, 'setTimeout');
17+
const { start } = delayExecution(delay);
18+
const startPromise = start();
19+
20+
jest.advanceTimersByTime(delay);
21+
22+
await expect(startPromise).resolves.toBe('completed');
23+
expect(spySetTimeout).toHaveBeenCalledTimes(1);
24+
});
25+
26+
test('취소된 경우 "cancelled"로 이행해야 한다.', async () => {
27+
const { start } = delayExecution(delay);
28+
const cancelToken: CancelToken = { isCancelled: true };
29+
const startPromise = start(cancelToken);
30+
31+
jest.advanceTimersByTime(delay);
32+
33+
await expect(startPromise).resolves.toBe('cancelled');
34+
});
35+
36+
test('clear가 호출되면 타임아웃을 해제해야 한다.', () => {
37+
const spyClearTimeout = jest.spyOn(globalThis, 'clearTimeout');
38+
const { start, clear } = delayExecution(delay);
39+
40+
start();
41+
clear();
42+
43+
expect(spyClearTimeout).toHaveBeenCalledTimes(1);
44+
});
45+
46+
test('잘못된 cancel token 제공 시 에러가 발생해야 한다.', async () => {
47+
const { start } = delayExecution(delay);
48+
49+
await expect(
50+
start({ isCancelled: null } as unknown as CancelToken)
51+
).rejects.toThrow('Invalid cancel token provided');
52+
await expect(start({} as CancelToken)).rejects.toThrow(
53+
'Invalid cancel token provided'
54+
);
55+
});
56+
57+
test('이전 타임아웃이 남아있는 경우 에러가 발생해야 한다.', async () => {
58+
const { start } = delayExecution(delay);
59+
60+
start(); // 첫 번째 시작
61+
62+
// 이전 타임아웃이 해제되기 전에 다시 시작 시도
63+
await expect(start()).rejects.toThrow('Previous timeout is still pending');
64+
});
65+
66+
test('내부에서 예외가 발생하는 경우 rejected 상태에 도달해야 한다.', async () => {
67+
(globalThis.setTimeout as unknown) = undefined; // 예외 테스트
68+
69+
const { start } = delayExecution(delay);
70+
71+
await expect(start()).rejects.toThrow();
72+
});
73+
});

src/utils/delayExecution.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
export interface CancelToken {
2+
isCancelled: boolean;
3+
}
4+
5+
type Status = 'completed' | 'cancelled';
6+
7+
/**
8+
* 지연 함수
9+
* @param ms 대기할 시간(밀리초).
10+
*
11+
* @returns start, clear 시작 및 정리 함수.
12+
*
13+
* @description
14+
* - 지정된 시간(ms) 동안 대기한 후 Promise를 해결합니다.
15+
* - 작업은 CancelToken을 사용하여 중간에 취소될 수 있습니다.
16+
*
17+
* @example
18+
* ```ts
19+
* await delayExecution(1000).start(); // <-- 1초 지연
20+
* task();
21+
* ```
22+
*/
23+
export const delayExecution = (ms: number) => {
24+
let timeoutId: NodeJS.Timeout | undefined;
25+
const defaultCancelToken: CancelToken = { isCancelled: false };
26+
27+
/**
28+
* 지연 시작 함수
29+
* @param cancelToken 작업을 취소할 수 있는 토큰.
30+
*
31+
* @returns Promise 작업이 완료되면 'completed', 취소되면 ‘cancelled’로 해결되는 Promise.
32+
*
33+
* @description
34+
* 주어진 시간(ms) 동안 대기한 후, 상태에 따라 Promise를 해결하거나 취소합니다.
35+
*/
36+
const startHandler = (cancelToken: CancelToken = defaultCancelToken) => {
37+
return new Promise<Status>((resolve, reject) => {
38+
if (!cancelToken || typeof cancelToken.isCancelled !== 'boolean') {
39+
return reject(new ReferenceError('Invalid cancel token provided'));
40+
}
41+
42+
if (timeoutId !== undefined) {
43+
return reject(new Error('Previous timeout is still pending'));
44+
}
45+
46+
if (cancelToken.isCancelled) {
47+
clearHandler();
48+
return resolve('cancelled'); // Promise를 resolve하여, 대기 상태에서 벗어나게 처리
49+
}
50+
51+
timeoutId = setTimeout(() => {
52+
return resolve('completed');
53+
}, ms);
54+
});
55+
};
56+
57+
const clearHandler = () => {
58+
if (timeoutId !== undefined) {
59+
clearTimeout(timeoutId);
60+
61+
timeoutId = undefined; // 타이머 ID 초기화
62+
}
63+
};
64+
65+
return {
66+
start: startHandler,
67+
clear: clearHandler,
68+
};
69+
};

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { delayExecution, type CancelToken } from './delayExecution';

0 commit comments

Comments
 (0)