diff --git a/.changeset/bright-locks-run.md b/.changeset/bright-locks-run.md new file mode 100644 index 00000000..5c79e709 --- /dev/null +++ b/.changeset/bright-locks-run.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': minor +--- + +feat(core): add useAsyncLock hook diff --git a/packages/core/src/hooks/useAsyncLock/index.ts b/packages/core/src/hooks/useAsyncLock/index.ts new file mode 100644 index 00000000..e37a4c7f --- /dev/null +++ b/packages/core/src/hooks/useAsyncLock/index.ts @@ -0,0 +1 @@ +export { useAsyncLock } from './useAsyncLock.ts'; diff --git a/packages/core/src/hooks/useAsyncLock/ko/useAsyncLock.md b/packages/core/src/hooks/useAsyncLock/ko/useAsyncLock.md new file mode 100644 index 00000000..1d9d5409 --- /dev/null +++ b/packages/core/src/hooks/useAsyncLock/ko/useAsyncLock.md @@ -0,0 +1,59 @@ +# useAsyncLock + +`useAsyncLock`은 비동기 작업이 겹쳐서 실행되지 않도록 막는 리액트 훅이에요. 하나의 콜백이 실행 중일 때 추가 호출이 들어오면 콜백을 실행하지 않고 blocked 결과를 반환해요. + +## 인터페이스 + +```ts +function useAsyncLock(): { + runWithLock: ( + callback: () => Promise | T + ) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>; + isLocked: () => boolean; +}; +``` + +### 파라미터 + +### 반환 값 + +를 반환해요.', +}, +{ +name: 'isLocked', +type: '() => boolean', +description: '현재 락이 잡혀 있는지 반환해요.', +}, +]" +/> + +## 예시 + +```tsx +function SubmitButton() { + const { runWithLock } = useAsyncLock(); + + const handleClick = async () => { + const result = await runWithLock(async () => { + return submitForm(); + }); + + if (result.status === 'blocked') { + return; + } + + console.log(result.data); + }; + + return ; +} +``` diff --git a/packages/core/src/hooks/useAsyncLock/useAsyncLock.md b/packages/core/src/hooks/useAsyncLock/useAsyncLock.md new file mode 100644 index 00000000..669fe3c5 --- /dev/null +++ b/packages/core/src/hooks/useAsyncLock/useAsyncLock.md @@ -0,0 +1,59 @@ +# useAsyncLock + +`useAsyncLock` is a React hook that prevents overlapping execution of asynchronous work. While one callback is running, additional calls are skipped and return a blocked result. + +## Interface + +```ts +function useAsyncLock(): { + runWithLock: ( + callback: () => Promise | T + ) => Promise<{ status: 'executed'; data: T } | { status: 'blocked' }>; + isLocked: () => boolean; +}; +``` + +### Parameters + +### Return Value + + without calling the callback.', +}, +{ +name: 'isLocked', +type: '() => boolean', +description: 'Returns whether the lock is currently held.', +}, +]" +/> + +## Example + +```tsx +function SubmitButton() { + const { runWithLock } = useAsyncLock(); + + const handleClick = async () => { + const result = await runWithLock(async () => { + return submitForm(); + }); + + if (result.status === 'blocked') { + return; + } + + console.log(result.data); + }; + + return ; +} +``` diff --git a/packages/core/src/hooks/useAsyncLock/useAsyncLock.spec.ts b/packages/core/src/hooks/useAsyncLock/useAsyncLock.spec.ts new file mode 100644 index 00000000..a8517363 --- /dev/null +++ b/packages/core/src/hooks/useAsyncLock/useAsyncLock.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useAsyncLock } from './useAsyncLock.ts'; + +describe('useAsyncLock', () => { + it('is safe on server side rendering', () => { + const result = renderHookSSR.serverOnly(() => useAsyncLock()); + + expect(result.current.isLocked()).toBe(false); + }); + + it('should execute a callback when the lock is available', async () => { + const callback = vi.fn(() => 'done'); + const { result } = await renderHookSSR(() => useAsyncLock()); + + await expect(result.current.runWithLock(callback)).resolves.toEqual({ + status: 'executed', + data: 'done', + }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should block overlapping executions', async () => { + let resolvePending: (value: string) => void = () => {}; + const pending = new Promise(resolve => { + resolvePending = resolve; + }); + const firstCallback = vi.fn(() => pending); + const secondCallback = vi.fn(() => 'blocked'); + const { result } = renderHookSSR(() => useAsyncLock()); + + const firstResult = result.current.runWithLock(firstCallback); + + expect(result.current.isLocked()).toBe(true); + await expect(result.current.runWithLock(secondCallback)).resolves.toEqual({ + status: 'blocked', + }); + expect(secondCallback).not.toHaveBeenCalled(); + + resolvePending('done'); + await expect(firstResult).resolves.toEqual({ + status: 'executed', + data: 'done', + }); + expect(result.current.isLocked()).toBe(false); + }); + + it('should release the lock after a callback rejects', async () => { + const error = new Error('failed'); + const rejectedCallback = vi.fn(async () => { + throw error; + }); + const nextCallback = vi.fn(() => 'next'); + const { result } = renderHookSSR(() => useAsyncLock()); + + await expect(result.current.runWithLock(rejectedCallback)).rejects.toThrow(error); + expect(result.current.isLocked()).toBe(false); + + await expect(result.current.runWithLock(nextCallback)).resolves.toEqual({ + status: 'executed', + data: 'next', + }); + }); + + it('should keep returned functions stable across rerenders', async () => { + const { result, rerender } = renderHookSSR(() => useAsyncLock()); + const initialValue = result.current; + + rerender(); + + expect(result.current).toBe(initialValue); + expect(result.current.runWithLock).toBe(initialValue.runWithLock); + expect(result.current.isLocked).toBe(initialValue.isLocked); + }); +}); diff --git a/packages/core/src/hooks/useAsyncLock/useAsyncLock.ts b/packages/core/src/hooks/useAsyncLock/useAsyncLock.ts new file mode 100644 index 00000000..a9d64ab6 --- /dev/null +++ b/packages/core/src/hooks/useAsyncLock/useAsyncLock.ts @@ -0,0 +1,59 @@ +import { useCallback, useMemo, useRef } from 'react'; + +type AsyncLockResult = { status: 'executed'; data: T } | { status: 'blocked' }; + +type UseAsyncLockReturn = { + runWithLock: (callback: () => Promise | T) => Promise>; + isLocked: () => boolean; +}; + +/** + * @description + * `useAsyncLock` is a React hook that prevents overlapping execution of asynchronous work. + * While one callback is running, additional calls are skipped and return a blocked result. + * + * @returns {UseAsyncLockReturn} An object containing: + * - runWithLock `(callback: () => Promise | T) => Promise>` - Runs a callback only when the lock is available. + * - isLocked `() => boolean` - Returns whether the lock is currently held. + * + * @example + * const { runWithLock } = useAsyncLock(); + * + * async function handleSubmit() { + * const result = await runWithLock(async () => { + * return submitForm(); + * }); + * + * if (result.status === 'blocked') { + * return; + * } + * + * console.log(result.data); + * } + */ +export function useAsyncLock(): UseAsyncLockReturn { + const isLockedRef = useRef(false); + + const runWithLock = useCallback(async function runWithLock( + callback: () => Promise | T + ): Promise> { + if (isLockedRef.current) { + return { status: 'blocked' }; + } + + isLockedRef.current = true; + + try { + const data = await callback(); + return { status: 'executed', data }; + } finally { + isLockedRef.current = false; + } + }, []); + + const isLocked = useCallback(function isLocked() { + return isLockedRef.current; + }, []); + + return useMemo(() => ({ runWithLock, isLocked }), [runWithLock, isLocked]); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1a80465e..9ea3fa2c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,7 @@ export { ImpressionArea } from './components/ImpressionArea/index.ts'; export { Separated } from './components/Separated/index.ts'; export { SwitchCase } from './components/SwitchCase/index.ts'; export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts'; +export { useAsyncLock } from './hooks/useAsyncLock/index.ts'; export { useBooleanState } from './hooks/useBooleanState/index.ts'; export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts'; export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts';