-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/compt 30 state storage hooks #3
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,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` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1 @@ | ||
| #!/usr/bin/env sh | ||
| . "$(dirname -- "$0")/_/husky.sh" | ||
|
|
||
| npx lint-staged |
| 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'; |
| 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
|
||
| 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
|
||
| return; | ||
| } | ||
|
|
||
| try { | ||
| storage.setItem(key, JSON.stringify(value)); | ||
| } catch { | ||
| // Swallow write errors (quota/security) while keeping hook state usable. | ||
| } | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| 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
|
||
| setDebouncedValue(value); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| window.clearTimeout(timeoutId); | ||
|
Check warning on line 12 in src/hooks/useDebounce.ts
|
||
| }; | ||
| }, [value, delay]); | ||
|
|
||
| return debouncedValue; | ||
| } | ||
| 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
|
||
| window.localStorage.clear(); | ||
|
Check warning on line 9 in src/hooks/useLocalStorage.test.ts
|
||
| } | ||
| 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
|
||
|
|
||
| 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
|
||
| 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
|
||
|
|
||
| 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
|
||
| }); | ||
| 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
|
||
|
|
||
| const [storedValue, setStoredValue] = useState<T>(() => { | ||
| return readStorageValue(storage, key, initialValue); | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| writeStorageValue(storage, key, storedValue); | ||
| }, [key, storedValue, storage]); | ||
|
|
||
| return [storedValue, setStoredValue]; | ||
| } | ||
|
Comment on lines
+5
to
+17
|
||
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.
useDebouncedirectly referenceswindow.setTimeout/clearTimeout. The repo guidelines state hooks must guard allwindow/documentaccess (typeof window === 'undefined'). Consider using the globalsetTimeout/clearTimeout(orglobalThis) and/or short-circuiting whenwindowis unavailable to keep the hook usable outside the browser (SSR/tests/non-DOM runtimes).