Skip to content
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: 24 additions & 0 deletions .changeset/COMPT-32-async-lifecycle-hooks.md
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`
10 changes: 8 additions & 2 deletions .github/instructions/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@

- **State & Storage** (COMPT-30 ✅) — `useDebounce`, `useLocalStorage`, `useSessionStorage`
- **DOM & Events** (COMPT-31 ✅) — `useMediaQuery`, `useWindowSize`, `useClickOutside`, `useIntersectionObserver`
- **Async & Lifecycle** — _(upcoming)_
<<<<<<< HEAD
Copy link

Copilot AI Mar 30, 2026

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).

Suggested change
<<<<<<< HEAD

Copilot uses AI. Check for mistakes.
- **Async & Lifecycle** (COMPT-32 ✅) — `usePrevious`, `useToggle`, `useInterval`, `useTimeout`, `useIsFirstRender`

### Module Responsibilities:

- Generic, fully-typed hooks with inference at call site
- SSR-safe (`typeof window === 'undefined'` guards in every hook)
- SSR-safe (`typeof window === 'undefined'` guards in every DOM hook)
- Zero runtime dependencies
- All listeners registered in `useEffect` and cleaned up on unmount
- WCAG-accessible patterns where applicable
Expand All @@ -48,6 +49,11 @@ src/
│ ├── useWindowSize.ts # COMPT-31 ✅
│ ├── useClickOutside.ts # COMPT-31 ✅
│ ├── useIntersectionObserver.ts # COMPT-31 ✅
│ ├── usePrevious.ts # COMPT-32 ✅
│ ├── useToggle.ts # COMPT-32 ✅
│ ├── useInterval.ts # COMPT-32 ✅
│ ├── useTimeout.ts # COMPT-32 ✅
│ ├── useIsFirstRender.ts # COMPT-32 ✅
│ └── index.ts # Hook barrel
├── utils/ # Framework-agnostic utils
│ ├── noop.ts
Expand Down
109 changes: 109 additions & 0 deletions src/hooks/__tests__/useInterval.test.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);
});
});
30 changes: 30 additions & 0 deletions src/hooks/__tests__/useIsFirstRender.test.ts
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);
});
});
47 changes: 47 additions & 0 deletions src/hooks/__tests__/usePrevious.test.ts
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);
});
});
92 changes: 92 additions & 0 deletions src/hooks/__tests__/useTimeout.test.ts
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);
});
});
64 changes: 64 additions & 0 deletions src/hooks/__tests__/useToggle.test.ts
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';
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

Test files for existing hooks are colocated in src/hooks (e.g., src/hooks/useDebounce.test.ts, src/hooks/useWindowSize.test.ts). These new tests live under src/hooks/__tests__, which is inconsistent and makes the test layout harder to scan. Consider colocating these tests alongside the corresponding hook files for consistency.

Suggested change
import { useToggle } from '../useToggle';
import { useToggle } from './useToggle';

Copilot uses AI. Check for mistakes.

describe('useToggle', () => {
it('initializes to false by default', () => {
const { result } = renderHook(() => useToggle());
expect(result.current[0]).toBe(false);
});

it('initializes to provided initial value', () => {
const { result } = renderHook(() => useToggle(true));
expect(result.current[0]).toBe(true);
});

it('toggles from false to true', () => {
const { result } = renderHook(() => useToggle(false));

act(() => {
result.current[1]();
});

expect(result.current[0]).toBe(true);
});

it('toggles from true to false', () => {
const { result } = renderHook(() => useToggle(true));

act(() => {
result.current[1]();
});

expect(result.current[0]).toBe(false);
});

it('toggles multiple times correctly', () => {
const { result } = renderHook(() => useToggle(false));

act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);

act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(false);

act(() => {
result.current[1]();
});
expect(result.current[0]).toBe(true);
});

it('returns a stable callback reference across renders', () => {
const { result, rerender } = renderHook(() => useToggle());

const firstToggle = result.current[1];
rerender();
const secondToggle = result.current[1];

expect(firstToggle).toBe(secondToggle);
});
});
Loading
Loading