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
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
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 changeset frontmatter targets "@ciscode/reactts-developerkit", but package.json declares the package name as @ciscode/hooks-kit. With the wrong package name, Changesets won’t version/release the actual package. Update the changeset to reference the real package name.

Suggested change
"@ciscode/reactts-developerkit": minor
"@ciscode/hooks-kit": minor

Copilot uses AI. Check for mistakes.
---

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

This doc states the package name is @ciscode/reactts-developerkit, but package.json currently uses @ciscode/hooks-kit. Please align the documentation with the actual published package name to avoid confusing consumers/contributors.

Suggested change
**Package**: `@ciscode/reactts-developerkit`
**Package**: `@ciscode/hooks-kit`

Copilot uses AI. Check for mistakes.
**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
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.

.husky/pre-commit no longer has a shebang or Husky bootstrap (. "$(dirname -- "$0")/_/husky.sh"). Git executes hooks as standalone programs; without a shebang this commonly fails with an “exec format error” and the hook won’t run. Restore the standard Husky header so the hook is executable and runs reliably across environments.

Copilot uses AI. Check for mistakes.
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;

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-x02rRXya9pO3QZmz&open=AZ0-x02rRXya9pO3QZmz&pullRequest=6
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-x02rRXya9pO3QZm0&open=AZ0-x02rRXya9pO3QZm0&pullRequest=6
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-x03yRXya9pO3QZm-&open=AZ0-x03yRXya9pO3QZm-&pullRequest=6
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-x03yRXya9pO3QZm_&open=AZ0-x03yRXya9pO3QZm_&pullRequest=6
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 breaks the repo’s SSR-safety convention (all window access must be guarded) and also fails in non-browser React environments where window is undefined. Prefer globalThis.setTimeout/globalThis.clearTimeout or the unqualified setTimeout/clearTimeout (and/or guard with typeof window === 'undefined'). See .github/instructions/copilot-instructions.md (SSR-safe rule).

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-x03ZRXya9pO3QZm3&open=AZ0-x03ZRXya9pO3QZm3&pullRequest=6
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-x03ZRXya9pO3QZm4&open=AZ0-x03ZRXya9pO3QZm4&pullRequest=6
}
vi.unstubAllGlobals();
});

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-x03ZRXya9pO3QZm5&open=AZ0-x03ZRXya9pO3QZm5&pullRequest=6

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-x03ZRXya9pO3QZm6&open=AZ0-x03ZRXya9pO3QZm6&pullRequest=6
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-x03ZRXya9pO3QZm7&open=AZ0-x03ZRXya9pO3QZm7&pullRequest=6

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');
});
Comment on lines +42 to +47
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.

This test is labeled as verifying the hook’s SSR guard, but it never calls useLocalStorage; it only tests readStorageValue. That leaves useLocalStorage’s SSR behavior untested and can let regressions slip through. Either rename/move this test to a storage helper test suite, or add a test that renders useLocalStorage with window undefined and asserts it returns the initial value without throwing.

Copilot generated this review using guidance from repository custom instructions.
});
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.window` over `window`.

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

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-x03lRXya9pO3QZm9&open=AZ0-x03lRXya9pO3QZm9&pullRequest=6

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

useEffect(() => {
writeStorageValue(storage, key, storedValue);
}, [key, storedValue, storage]);
Comment on lines +13 to +14
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.

When key changes, the hook keeps the previous storedValue (since the useState initializer only runs once) but the effect will start writing that old value under the new key. This can overwrite an existing value for the new key and never re-read it. Either document that key must be stable, or add logic to re-read/reset state when key changes (and avoid overwriting until after the re-read).

Suggested change
writeStorageValue(storage, key, storedValue);
}, [key, storedValue, storage]);
const value = readStorageValue(storage, key, initialValue);
setStoredValue(value);
}, [key, storage, initialValue]);
useEffect(() => {
writeStorageValue(storage, key, storedValue);
}, [storedValue, storage]);

Copilot uses AI. Check for mistakes.

return [storedValue, setStoredValue];
}
Loading
Loading