Skip to content
Closed
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
21 changes: 21 additions & 0 deletions .changeset/COMPT-30-state-storage-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@ciscode/reactts-developerkit": minor
---

feat(COMPT-30): add state & storage hooks — useDebounce, useLocalStorage, useSessionStorage

First batch of production-ready hooks for HooksKit (epic COMPT-2).

**New hooks:**

- `useDebounce<T>(value, delay)` — returns debounced value; resets timer on value or delay change
- `useLocalStorage<T>(key, initial)` — syncs with `localStorage`, SSR-safe, JSON serialization, parse-error fallback
- `useSessionStorage<T>(key, initial)` — same pattern for `sessionStorage`

**Implementation details:**

- Shared `storage.ts` helper (`readStorageValue` / `writeStorageValue`) encapsulates SSR guard (`typeof window === 'undefined'`) and JSON parse fallback
- Generics inferred at call site — no manual type params required
- Zero runtime dependencies
- `tsc --noEmit` passes, ESLint passes (0 warnings), 13/13 tests pass, coverage ≥ 91%
- All three hooks exported from `src/index.ts`
88 changes: 45 additions & 43 deletions .github/instructions/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,58 +1,61 @@
# Copilot Instructions - React Component Library
# Copilot Instructions - HooksKit

> **Purpose**: Development guidelines for React component libraries - reusable, well-structured components for modern apps.
> **Purpose**: Development guidelines for HooksKit — production-ready React hooks with zero runtime deps.

---

## 🎯 Module Overview

**Package**: `@ciscode/ui-components` (example)
**Type**: React Component Library
**Package**: `@ciscode/reactts-developerkit`
**Epic**: COMPT-2 — HooksKit
**Type**: React Hooks Library
**Framework**: React 18+, TypeScript 5+
**Build**: Vite/tsup
**Build**: tsup
**Testing**: Vitest + React Testing Library
**Distribution**: NPM package
**Purpose**: Reusable, production-ready React components for building modern UIs
**Purpose**: 12 production-ready React hooks. Zero runtime deps. SSR-safe.

### Typical Module Responsibilities:
### Hook Groups:

- Atomic UI components (Button, Input, Card, etc.)
- Composite components (Form, Modal, Navigation, etc.)
- Hooks for common patterns
- Type definitions and props interfaces
- Accessibility compliance (WCAG 2.1 AA)
- Theming and customization
- Comprehensive documentation
- **State & Storage** — `useDebounce`, `useLocalStorage`, `useSessionStorage`
- **DOM & Events** — _(upcoming)_
- **Async & Lifecycle** — _(upcoming)_

### Module Responsibilities:

- Generic, fully-typed hooks with inference at call site
- SSR-safe (all `window`/`document` access guarded with `typeof window === 'undefined'`)
- JSON serialization for storage hooks (parse-error fallback to initial value)
- Zero runtime dependencies
- WCAG-accessible patterns where applicable
- Comprehensive tests (hooks ≥ 90% coverage)

---

## 🏗️ Module Structure

```
src/
├── components/ # React components
│ ├── Button/
│ │ ├── Button.tsx # Component
│ │ ├── Button.test.tsx # Tests
│ │ ├── Button.types.ts # Props types
│ │ └── index.ts # Exports
│ ├── Input/
│ ├── Modal/
│ └── Form/
├── hooks/ # Custom hooks
│ ├── useModal.ts
│ ├── useForm.ts
│ └── useModal.test.ts
├── context/ # Context providers
│ ├── ThemeContext.tsx
│ └── FormContext.tsx
├── types/ # TypeScript types
│ └── common.types.ts
├── utils/ # Utilities
│ └── classNameUtils.ts
└── index.ts # Public API
├── components/ # Minimal supporting components
│ ├── NoopButton.tsx
│ └── index.ts
├── hooks/ # All public hooks
│ ├── storage.ts # Internal SSR-safe storage helpers
│ ├── useDebounce.ts # COMPT-30 ✅
│ ├── useDebounce.test.ts
│ ├── useLocalStorage.ts # COMPT-30 ✅
│ ├── useLocalStorage.test.ts
│ ├── useSessionStorage.ts # COMPT-30 ✅
│ ├── useSessionStorage.test.ts
│ └── index.ts # Hook barrel
├── utils/ # Framework-agnostic utils
│ ├── noop.ts
│ └── index.ts
└── index.ts # Public API (only entry point)
```

> ⚠️ Only export from `src/index.ts`. Deep imports are forbidden.

---

## 📝 Naming Conventions
Expand Down Expand Up @@ -277,18 +280,17 @@ export type { ButtonProps, ModalProps, InputProps, FormProps } from './component
**1. Branch Creation:**

```bash
feature/UI-MODULE-123-add-datepicker
bugfix/UI-MODULE-456-fix-modal-focus
refactor/UI-MODULE-789-extract-button-styles
feat/COMPT-30-state-storage-hooks
bugfix/COMPT-XX-short-description
```

**2. Task Documentation:**
> Branch names must reference the Jira ticket (COMPT-XX format). Pull from `develop` before opening PR.

Create task file:
**PR targets:**

```
docs/tasks/active/UI-MODULE-123-add-datepicker.md
```
- Feature branches → `develop`
- `develop` → `master` on Friday release only
- Never open a PR directly to `master`

**Task structure:**

Expand Down
3 changes: 0 additions & 3 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// Example placeholder export — replace with real hooks later.
export const __hooks_placeholder = true;

Comment on lines 1 to 3
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.

__hooks_placeholder is exported from the hooks barrel and therefore becomes part of the package’s public API via src/index.ts. Now that real hooks are present, this placeholder export should be removed to avoid shipping a permanent, undocumented API surface.

Suggested change
// Example placeholder export — replace with real hooks later.
export const __hooks_placeholder = true;

Copilot uses AI. Check for mistakes.
export * from './useDebounce';
export * from './useLocalStorage';
export * from './useSessionStorage';
export * from './useNoop';
29 changes: 29 additions & 0 deletions src/hooks/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function readStorageValue<T>(storage: Storage | undefined, key: string, initialValue: T): T {
if (typeof window === 'undefined' || storage === undefined) {

Check warning on line 2 in src/hooks/storage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o38svjuSEm7qfoaM&open=AZ0-o38svjuSEm7qfoaM&pullRequest=3
return initialValue;
}

try {
const item = storage.getItem(key);

if (item === null) {
return initialValue;
}

return JSON.parse(item) as T;
} catch {
return initialValue;
}
}

export function writeStorageValue<T>(storage: Storage | undefined, key: string, value: T): void {
if (typeof window === 'undefined' || storage === undefined) {

Check warning on line 20 in src/hooks/storage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o38svjuSEm7qfoaN&open=AZ0-o38svjuSEm7qfoaN&pullRequest=3
return;
}

try {
storage.setItem(key, JSON.stringify(value));
} catch {
// Swallow write errors (quota/security) while keeping hook state usable.
}
}
78 changes: 78 additions & 0 deletions src/hooks/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
afterEach(() => {
vi.useRealTimers();
});

it('returns the initial value immediately and updates after delay', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'first', delay: 100 },
});

expect(result.current).toBe('first');

rerender({ value: 'second', delay: 100 });
expect(result.current).toBe('first');

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe('second');
});

it('resets the timer when value changes', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'a', delay: 100 },
});

rerender({ value: 'b', delay: 100 });

act(() => {
vi.advanceTimersByTime(50);
});

rerender({ value: 'c', delay: 100 });

act(() => {
vi.advanceTimersByTime(50);
});

expect(result.current).toBe('a');

act(() => {
vi.advanceTimersByTime(50);
});

expect(result.current).toBe('c');
});

it('resets the timer when delay changes', () => {
vi.useFakeTimers();

const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 1, delay: 100 },
});

rerender({ value: 2, delay: 200 });

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe(1);

act(() => {
vi.advanceTimersByTime(100);
});

expect(result.current).toBe(2);
});
});
17 changes: 17 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timeoutId = window.setTimeout(() => {

Check warning on line 7 in src/hooks/useDebounce.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39xvjuSEm7qfoaX&open=AZ0-o39xvjuSEm7qfoaX&pullRequest=3
setDebouncedValue(value);
}, delay);

return () => {
window.clearTimeout(timeoutId);

Check warning on line 12 in src/hooks/useDebounce.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39xvjuSEm7qfoaY&open=AZ0-o39xvjuSEm7qfoaY&pullRequest=3
Comment on lines +7 to +12
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.

useDebounce uses window.setTimeout/window.clearTimeout. This will throw in non-DOM runtimes (e.g., React Native/tests without JSDOM) and contradicts the repo’s stated SSR-safety goal. Prefer globalThis.setTimeout/globalThis.clearTimeout (or the unqualified setTimeout/clearTimeout) so the hook doesn’t require window.

Suggested change
const timeoutId = window.setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
window.clearTimeout(timeoutId);
const timeoutId = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeoutId);

Copilot uses AI. Check for mistakes.
};
}, [value, delay]);

return debouncedValue;
}
48 changes: 48 additions & 0 deletions src/hooks/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import { readStorageValue } from './storage';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
afterEach(() => {
if (typeof window !== 'undefined') {

Check warning on line 8 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39avjuSEm7qfoaQ&open=AZ0-o39avjuSEm7qfoaQ&pullRequest=3
window.localStorage.clear();

Check warning on line 9 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39avjuSEm7qfoaR&open=AZ0-o39avjuSEm7qfoaR&pullRequest=3
}
vi.unstubAllGlobals();
Comment on lines +8 to +11
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.

afterEach clears localStorage before calling vi.unstubAllGlobals(). If a test stubs window to undefined, the clear is skipped and storage can leak into later tests. Unstub/restore globals first (or use a finally) and then clear storage to keep cleanup reliable regardless of test order.

Suggested change
if (typeof window !== 'undefined') {
window.localStorage.clear();
}
vi.unstubAllGlobals();
vi.unstubAllGlobals();
if (typeof window !== 'undefined') {
window.localStorage.clear();
}

Copilot uses AI. Check for mistakes.
});

it('reads existing JSON value from localStorage', () => {
window.localStorage.setItem('user', JSON.stringify({ name: 'Ana' }));

Check warning on line 15 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39avjuSEm7qfoaS&open=AZ0-o39avjuSEm7qfoaS&pullRequest=3

const { result } = renderHook(() => useLocalStorage('user', { name: 'Default' }));

expect(result.current[0]).toEqual({ name: 'Ana' });
});

it('syncs updates to localStorage with JSON serialization', () => {
const { result } = renderHook(() => useLocalStorage('count', 0));
expectTypeOf(result.current[0]).toEqualTypeOf<number>();

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

expect(window.localStorage.getItem('count')).toBe('5');

Check warning on line 30 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39avjuSEm7qfoaT&open=AZ0-o39avjuSEm7qfoaT&pullRequest=3
expect(result.current[0]).toBe(5);
});

it('returns initial value on JSON parse error', () => {
window.localStorage.setItem('bad-json', '{ invalid json');

Check warning on line 35 in src/hooks/useLocalStorage.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39avjuSEm7qfoaU&open=AZ0-o39avjuSEm7qfoaU&pullRequest=3

const { result } = renderHook(() => useLocalStorage('bad-json', 42));

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

it('returns initial value when window is undefined (SSR guard)', () => {
vi.stubGlobal('window', undefined);

const value = readStorageValue(undefined, 'ssr', 'fallback');
expect(value).toBe('fallback');
});
});
17 changes: 17 additions & 0 deletions src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { readStorageValue, writeStorageValue } from './storage';

export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const storage = typeof window === 'undefined' ? undefined : window.localStorage;

Check warning on line 6 in src/hooks/useLocalStorage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39mvjuSEm7qfoaW&open=AZ0-o39mvjuSEm7qfoaW&pullRequest=3

Check warning on line 6 in src/hooks/useLocalStorage.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis.window` over `window`.

See more on https://sonarcloud.io/project/issues?id=CISCODE-MA_HooksKit&issues=AZ0-o39mvjuSEm7qfoaV&open=AZ0-o39mvjuSEm7qfoaV&pullRequest=3
Comment on lines +5 to +6
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.

Accessing window.localStorage can throw (e.g., in some privacy modes or sandboxed iframes). Since this happens during render, it would crash the hook before the try/catch in readStorageValue/writeStorageValue can help. Consider wrapping the storage acquisition in a try/catch (returning undefined on failure) or centralizing it in a safe helper.

Suggested change
export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const storage = typeof window === 'undefined' ? undefined : window.localStorage;
function getLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
try {
return window.localStorage;
} catch {
return undefined;
}
}
export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
const storage = getLocalStorage();

Copilot uses AI. Check for mistakes.

const [storedValue, setStoredValue] = useState<T>(() => {
return readStorageValue(storage, key, initialValue);
});

useEffect(() => {
writeStorageValue(storage, key, storedValue);
}, [key, storedValue, storage]);

return [storedValue, setStoredValue];
}
Loading
Loading