diff --git a/.changeset/warm-tigers-glow.md b/.changeset/warm-tigers-glow.md new file mode 100644 index 00000000..27e1faaf --- /dev/null +++ b/.changeset/warm-tigers-glow.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +feat(core/hooks): add 'useHistory' hook diff --git a/packages/core/src/hooks/useHistory/index.ts b/packages/core/src/hooks/useHistory/index.ts new file mode 100644 index 00000000..a660cb7c --- /dev/null +++ b/packages/core/src/hooks/useHistory/index.ts @@ -0,0 +1 @@ +export { useHistory } from './useHistory.ts'; diff --git a/packages/core/src/hooks/useHistory/ko/useHistory.md b/packages/core/src/hooks/useHistory/ko/useHistory.md new file mode 100644 index 00000000..a00a2e8b --- /dev/null +++ b/packages/core/src/hooks/useHistory/ko/useHistory.md @@ -0,0 +1,147 @@ +# useHistory + +`useHistory`는 값의 변경 이력을 추적하고 undo/redo 기능을 제공하는 리액트 훅이에요. `setValue`를 호출할 때마다 값이 이력 스택에 기록되고, `undo`와 `redo`를 사용해 이전 값을 탐색할 수 있어요. + +## 인터페이스 + +```ts +function useHistory( + initialValue: T, + options?: { capacity?: number } +): { + value: T; + setValue: (value: T) => void; + history: readonly T[]; + pointer: number; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; + clear: () => void; +}; +``` + +### 파라미터 + + + + + +### 반환 값 + + + +## 예시 + +```tsx +function TextEditor() { + const { value, setValue, undo, redo, canUndo, canRedo } = useHistory(''); + + return ( +
+ setValue(e.target.value)} /> + + +
+ ); +} +``` + +```tsx +function Counter() { + const { value, setValue, undo, canUndo, clear } = useHistory(0, { + capacity: 10, + }); + + return ( +
+

Count: {value}

+ + + +
+ ); +} +``` diff --git a/packages/core/src/hooks/useHistory/useHistory.md b/packages/core/src/hooks/useHistory/useHistory.md new file mode 100644 index 00000000..da548a04 --- /dev/null +++ b/packages/core/src/hooks/useHistory/useHistory.md @@ -0,0 +1,147 @@ +# useHistory + +`useHistory` is a React hook that tracks the change history of a value and provides undo/redo functionality. Each time `setValue` is called, the value is recorded in the history stack, and you can navigate through past values using `undo` and `redo`. + +## Interface + +```ts +function useHistory( + initialValue: T, + options?: { capacity?: number } +): { + value: T; + setValue: (value: T) => void; + history: readonly T[]; + pointer: number; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; + clear: () => void; +}; +``` + +### Parameters + + + + + +### Return Value + + + +## Example + +```tsx +function TextEditor() { + const { value, setValue, undo, redo, canUndo, canRedo } = useHistory(''); + + return ( +
+ setValue(e.target.value)} /> + + +
+ ); +} +``` + +```tsx +function Counter() { + const { value, setValue, undo, canUndo, clear } = useHistory(0, { + capacity: 10, + }); + + return ( +
+

Count: {value}

+ + + +
+ ); +} +``` diff --git a/packages/core/src/hooks/useHistory/useHistory.spec.ts b/packages/core/src/hooks/useHistory/useHistory.spec.ts new file mode 100644 index 00000000..b0de877a --- /dev/null +++ b/packages/core/src/hooks/useHistory/useHistory.spec.ts @@ -0,0 +1,248 @@ +import { act } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useHistory } from './useHistory.ts'; + +describe('useHistory', () => { + it('is safe on server side rendering', async () => { + const result = renderHookSSR.serverOnly(() => useHistory('initial')); + + expect(result.current.value).toBe('initial'); + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + expect(result.current.pointer).toBe(0); + expect(result.current.history).toEqual(['initial']); + }); + + it('should initialize with the given value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + expect(result.current.value).toBe(0); + expect(result.current.pointer).toBe(0); + expect(result.current.history).toEqual([0]); + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + }); + + it('should record value changes in history', async () => { + const { result } = await renderHookSSR(() => useHistory('a')); + + await act(async () => { + result.current.setValue('b'); + }); + + await act(async () => { + result.current.setValue('c'); + }); + + expect(result.current.value).toBe('c'); + expect(result.current.history).toEqual(['a', 'b', 'c']); + expect(result.current.pointer).toBe(2); + }); + + it('should undo to the previous value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.undo(); + }); + + expect(result.current.value).toBe(1); + expect(result.current.pointer).toBe(1); + expect(result.current.canUndo).toBe(true); + expect(result.current.canRedo).toBe(true); + }); + + it('should redo to the next value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.undo(); + }); + + await act(async () => { + result.current.undo(); + }); + + await act(async () => { + result.current.redo(); + }); + + expect(result.current.value).toBe(1); + expect(result.current.pointer).toBe(1); + expect(result.current.canUndo).toBe(true); + expect(result.current.canRedo).toBe(true); + }); + + it('should not undo past the initial value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.undo(); + }); + + expect(result.current.value).toBe(0); + expect(result.current.pointer).toBe(0); + expect(result.current.canUndo).toBe(false); + }); + + it('should not redo past the latest value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.redo(); + }); + + expect(result.current.value).toBe(1); + expect(result.current.pointer).toBe(1); + expect(result.current.canRedo).toBe(false); + }); + + it('should discard future history when setting a new value after undo', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.undo(); + }); + + await act(async () => { + result.current.setValue(3); + }); + + expect(result.current.value).toBe(3); + expect(result.current.history).toEqual([0, 1, 3]); + expect(result.current.pointer).toBe(2); + expect(result.current.canRedo).toBe(false); + }); + + it('should limit history to the given capacity', async () => { + const { result } = await renderHookSSR(() => useHistory(0, { capacity: 3 })); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.setValue(3); + }); + + await act(async () => { + result.current.setValue(4); + }); + + expect(result.current.history).toEqual([2, 3, 4]); + expect(result.current.history).toHaveLength(3); + expect(result.current.value).toBe(4); + expect(result.current.pointer).toBe(2); + }); + + it('should clear history and keep the current value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.clear(); + }); + + expect(result.current.value).toBe(2); + expect(result.current.history).toEqual([2]); + expect(result.current.pointer).toBe(0); + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + }); + + it('should handle consecutive setValue calls within a single act', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + result.current.setValue(2); + }); + + expect(result.current.value).toBe(2); + expect(result.current.history).toEqual([0, 1, 2]); + expect(result.current.pointer).toBe(2); + }); + + it('should keep only one entry when capacity is 1', async () => { + const { result } = await renderHookSSR(() => useHistory(0, { capacity: 1 })); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + expect(result.current.history).toEqual([2]); + expect(result.current.history).toHaveLength(1); + expect(result.current.value).toBe(2); + expect(result.current.canUndo).toBe(false); + }); + + it('should clear history after undo and keep the undone value', async () => { + const { result } = await renderHookSSR(() => useHistory(0)); + + await act(async () => { + result.current.setValue(1); + }); + + await act(async () => { + result.current.setValue(2); + }); + + await act(async () => { + result.current.undo(); + }); + + await act(async () => { + result.current.clear(); + }); + + expect(result.current.value).toBe(1); + expect(result.current.history).toEqual([1]); + expect(result.current.pointer).toBe(0); + }); +}); diff --git a/packages/core/src/hooks/useHistory/useHistory.ts b/packages/core/src/hooks/useHistory/useHistory.ts new file mode 100644 index 00000000..9733d9ac --- /dev/null +++ b/packages/core/src/hooks/useHistory/useHistory.ts @@ -0,0 +1,108 @@ +import { useMemo, useState } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +type UseHistoryOptions = { + capacity?: number; +}; + +/** + * @description + * `useHistory` is a React hook that tracks the change history of a value and provides undo/redo functionality. + * Each time `setValue` is called, the value is recorded in the history stack, and you can navigate through + * past values using `undo` and `redo`. + * + * @param {T} initialValue - The initial value. + * @param {UseHistoryOptions} [options] - Configuration options. + * @param {number} [options.capacity] - The maximum number of history entries to keep. When exceeded, the oldest entry is removed. Defaults to unlimited. + * + * @returns {object} An object containing: + * - `value` — The current value. + * - `setValue` — A function to set a new value and record it in the history. + * - `history` — A readonly array of all recorded values. + * - `pointer` — The current position in the history stack. + * - `undo` — A function to revert to the previous value. + * - `redo` — A function to move forward to the next value. + * - `canUndo` — Whether there is a previous value to revert to. + * - `canRedo` — Whether there is a next value to move forward to. + * - `clear` — A function to clear the history and reset to the current value. + * + * @example + * function TextEditor() { + * const { value, setValue, undo, redo, canUndo, canRedo } = useHistory(''); + * + * return ( + *
+ * setValue(e.target.value)} /> + * + * + *
+ * ); + * } + * + * @example + * function Counter() { + * const { value, setValue, undo, canUndo, clear } = useHistory(0, { capacity: 10 }); + * + * return ( + *
+ *

Count: {value}

+ * + * + * + *
+ * ); + * } + */ +export function useHistory(initialValue: T, options?: UseHistoryOptions) { + const { capacity } = options ?? {}; + + const [state, setState] = useState<{ history: T[]; pointer: number }>({ + history: [initialValue], + pointer: 0, + }); + + const setValue = usePreservedCallback((newValue: T) => { + setState(prev => { + const newHistory = [...prev.history.slice(0, prev.pointer + 1), newValue]; + + if (capacity !== undefined && capacity >= 1 && newHistory.length > capacity) { + newHistory.splice(0, newHistory.length - capacity); + } + + return { history: newHistory, pointer: newHistory.length - 1 }; + }); + }); + + const undo = usePreservedCallback(() => { + setState(prev => (prev.pointer > 0 ? { ...prev, pointer: prev.pointer - 1 } : prev)); + }); + + const redo = usePreservedCallback(() => { + setState(prev => (prev.pointer < prev.history.length - 1 ? { ...prev, pointer: prev.pointer + 1 } : prev)); + }); + + const clear = usePreservedCallback(() => { + setState(prev => ({ history: [prev.history[prev.pointer]], pointer: 0 })); + }); + + const { history, pointer } = state; + const value = history[pointer]; + const canUndo = pointer > 0; + const canRedo = pointer < history.length - 1; + + return useMemo( + () => ({ + value, + setValue, + history: history as readonly T[], + pointer, + undo, + redo, + canUndo, + canRedo, + clear, + }), + [value, setValue, history, pointer, undo, redo, canUndo, canRedo, clear] + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 975dd899..bd6d5584 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export { useDebounce } from './hooks/useDebounce/index.ts'; export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts'; export { useDoubleClick } from './hooks/useDoubleClick/index.ts'; export { useGeolocation } from './hooks/useGeolocation/index.ts'; +export { useHistory } from './hooks/useHistory/index.ts'; export { useImpressionRef } from './hooks/useImpressionRef/index.ts'; export { useInputState } from './hooks/useInputState/index.ts'; export { useIntersectionObserver } from './hooks/useIntersectionObserver/index.ts';