-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/compt 32 async lifecycle hooks #9
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| --- | ||
| "@ciscode/hooks-kit": minor | ||
| --- | ||
|
|
||
| feat(COMPT-32): add async & lifecycle hooks — usePrevious, useToggle, useInterval, useTimeout, useIsFirstRender | ||
|
|
||
| Third and final batch of production-ready hooks for HooksKit (epic COMPT-2). Completes the 12-hook surface. | ||
|
|
||
| **New hooks:** | ||
|
|
||
| - `usePrevious<T>(value)` — returns previous render value via state derivation; `undefined` on first render | ||
| - `useToggle(initial?)` — toggles boolean state with stable `useCallback` reference | ||
| - `useInterval(callback, delay | null)` — runs callback on interval; stops immediately when `delay` is `null`; always uses latest callback via ref | ||
| - `useTimeout(callback, delay | null)` — fires callback once after delay; cancels when `delay` is `null` or on unmount; always uses latest callback via ref | ||
| - `useIsFirstRender()` — returns `true` only on first render, `false` on all subsequent renders | ||
|
|
||
| **Implementation details:** | ||
|
|
||
| - `usePrevious` uses React state-derivation pattern (no ref read during render) to satisfy strict lint rules | ||
| - `useIsFirstRender` uses ref-based approach with scoped `eslint-disable` (only valid alternative; cannot use setState-in-effect or ref-read-in-render rules) | ||
| - All timer cleanup in `useEffect` return — verified under React StrictMode | ||
| - Zero runtime dependencies | ||
| - `tsc --noEmit` passes, ESLint passes (0 warnings), 25/25 tests pass, hooks coverage ≥ 98% | ||
| - All five hooks exported from `src/index.ts` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { act, renderHook } from '@testing-library/react'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { useInterval } from '../useInterval'; | ||
|
|
||
| describe('useInterval', () => { | ||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('fires callback at the given interval cadence', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| renderHook(() => useInterval(callback, 100)); | ||
|
|
||
| expect(callback).not.toHaveBeenCalled(); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(1); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(2); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(300); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(5); | ||
| }); | ||
|
|
||
| it('stops firing when delay changes to null', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| const { rerender } = renderHook( | ||
| ({ delay }: { delay: number | null }) => useInterval(callback, delay), | ||
| { initialProps: { delay: 100 as number | null } }, | ||
| ); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(200); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(2); | ||
|
|
||
| rerender({ delay: null }); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(500); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(2); | ||
| }); | ||
|
|
||
| it('does not fire when delay starts as null', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| renderHook(() => useInterval(callback, null)); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(1000); | ||
| }); | ||
| expect(callback).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('clears interval on unmount', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| const { unmount } = renderHook(() => useInterval(callback, 100)); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(1); | ||
|
|
||
| unmount(); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(500); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('always uses the latest callback reference', () => { | ||
| vi.useFakeTimers(); | ||
| const first = vi.fn(); | ||
| const second = vi.fn(); | ||
|
|
||
| const { rerender } = renderHook(({ cb }: { cb: () => void }) => useInterval(cb, 100), { | ||
| initialProps: { cb: first }, | ||
| }); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
| expect(first).toHaveBeenCalledTimes(1); | ||
|
|
||
| rerender({ cb: second }); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(100); | ||
| }); | ||
| expect(first).toHaveBeenCalledTimes(1); | ||
| expect(second).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { useIsFirstRender } from '../useIsFirstRender'; | ||
|
|
||
| describe('useIsFirstRender', () => { | ||
| it('returns true on first render', () => { | ||
| const { result } = renderHook(() => useIsFirstRender()); | ||
| expect(result.current).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false on subsequent renders', () => { | ||
| const { result, rerender } = renderHook(() => useIsFirstRender()); | ||
|
|
||
| expect(result.current).toBe(true); | ||
|
|
||
| rerender(); | ||
| expect(result.current).toBe(false); | ||
|
|
||
| rerender(); | ||
| expect(result.current).toBe(false); | ||
| }); | ||
|
|
||
| it('resets to true on fresh mount', () => { | ||
| const { result: first } = renderHook(() => useIsFirstRender()); | ||
| expect(first.current).toBe(true); | ||
|
|
||
| const { result: second } = renderHook(() => useIsFirstRender()); | ||
| expect(second.current).toBe(true); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { renderHook } from '@testing-library/react'; | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { usePrevious } from '../usePrevious'; | ||
|
|
||
| describe('usePrevious', () => { | ||
| it('returns undefined on first render', () => { | ||
| const { result } = renderHook(() => usePrevious('initial')); | ||
| expect(result.current).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns the previous value on subsequent renders', () => { | ||
| const { result, rerender } = renderHook(({ value }) => usePrevious(value), { | ||
| initialProps: { value: 'first' }, | ||
| }); | ||
|
|
||
| expect(result.current).toBeUndefined(); | ||
|
|
||
| rerender({ value: 'second' }); | ||
| expect(result.current).toBe('first'); | ||
|
|
||
| rerender({ value: 'third' }); | ||
| expect(result.current).toBe('second'); | ||
| }); | ||
|
|
||
| it('works with numeric values', () => { | ||
| const { result, rerender } = renderHook(({ value }) => usePrevious(value), { | ||
| initialProps: { value: 1 }, | ||
| }); | ||
|
|
||
| expect(result.current).toBeUndefined(); | ||
|
|
||
| rerender({ value: 2 }); | ||
| expect(result.current).toBe(1); | ||
| }); | ||
|
|
||
| it('works with object values', () => { | ||
| const obj1 = { a: 1 }; | ||
| const obj2 = { a: 2 }; | ||
|
|
||
| const { result, rerender } = renderHook(({ value }) => usePrevious(value), { | ||
| initialProps: { value: obj1 }, | ||
| }); | ||
|
|
||
| rerender({ value: obj2 }); | ||
| expect(result.current).toBe(obj1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import { act, renderHook } from '@testing-library/react'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { useTimeout } from '../useTimeout'; | ||
|
|
||
| describe('useTimeout', () => { | ||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('fires callback exactly once after delay', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| renderHook(() => useTimeout(callback, 200)); | ||
|
|
||
| expect(callback).not.toHaveBeenCalled(); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(200); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(1); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(1000); | ||
| }); | ||
| expect(callback).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('does not fire when delay is null', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| renderHook(() => useTimeout(callback, null)); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(1000); | ||
| }); | ||
| expect(callback).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('stops when delay changes to null before firing', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| const { rerender } = renderHook( | ||
| ({ delay }: { delay: number | null }) => useTimeout(callback, delay), | ||
| { initialProps: { delay: 500 as number | null } }, | ||
| ); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(200); | ||
| }); | ||
| rerender({ delay: null }); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(500); | ||
| }); | ||
| expect(callback).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('clears timeout on unmount', () => { | ||
| vi.useFakeTimers(); | ||
| const callback = vi.fn(); | ||
|
|
||
| const { unmount } = renderHook(() => useTimeout(callback, 300)); | ||
|
|
||
| unmount(); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(500); | ||
| }); | ||
| expect(callback).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('always uses the latest callback reference', () => { | ||
| vi.useFakeTimers(); | ||
| const first = vi.fn(); | ||
| const second = vi.fn(); | ||
|
|
||
| const { rerender } = renderHook(({ cb }: { cb: () => void }) => useTimeout(cb, 200), { | ||
| initialProps: { cb: first }, | ||
| }); | ||
|
|
||
| rerender({ cb: second }); | ||
|
|
||
| act(() => { | ||
| vi.advanceTimersByTime(200); | ||
| }); | ||
| expect(first).not.toHaveBeenCalled(); | ||
| expect(second).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||
| import { act, renderHook } from '@testing-library/react'; | ||||||
| import { describe, expect, it } from 'vitest'; | ||||||
| import { useToggle } from '../useToggle'; | ||||||
|
||||||
| import { useToggle } from '../useToggle'; | |
| import { useToggle } from './useToggle'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The file contains an unresolved merge conflict marker (
<<<<<<< HEAD). This will break the markdown/instructions and should be resolved (remove conflict markers and keep the intended content).