Feat/compt 32 async lifecycle hooks#9
Conversation
…useIsFirstRender - usePrevious<T>(value): previous render value via state-derivation; undefined on first render - useToggle(initial?): boolean toggle with stable useCallback reference - useInterval(callback, delay|null): fires at cadence, stops on null, latest callback via ref - useTimeout(callback, delay|null): fires once, cancels on null or unmount, latest callback via ref - useIsFirstRender(): true only on first render (scoped eslint-disable for intentional ref access) - All timer cleanup in useEffect return — StrictMode safe - Zero runtime deps; tsc --noEmit passes, lint passes, 25/25 tests, hooks coverage >= 98% - All five exported from src/hooks/index.ts -> src/index.ts - Changeset added, copilot-instructions.md updated with all three COMPT groups complete
- Moved all 5 hook test files from src/hooks/ to src/hooks/__tests__/ - Updated relative imports from ./hook to ../hook - No logic changes; all 25 tests still pass
|
There was a problem hiding this comment.
Pull request overview
Adds a new “Async & Lifecycle” set of hooks to the HooksKit package, along with tests and public exports.
Changes:
- Introduces five new hooks:
usePrevious,useToggle,useInterval,useTimeout,useIsFirstRender - Exports the new hooks from the hooks barrel (
src/hooks/index.ts) so they’re available viasrc/index.ts - Adds Vitest coverage for the new hooks and a changeset entry for a minor release
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useToggle.ts | New boolean toggle hook with stable callback |
| src/hooks/useTimeout.ts | New timeout hook (delay-controlled) |
| src/hooks/usePrevious.ts | New previous-value hook |
| src/hooks/useIsFirstRender.ts | New first-render detection hook |
| src/hooks/useInterval.ts | New interval hook (delay-controlled) |
| src/hooks/index.ts | Exports the newly added hooks |
| src/hooks/tests/useToggle.test.ts | Adds tests for useToggle |
| src/hooks/tests/useTimeout.test.ts | Adds tests for useTimeout |
| src/hooks/tests/usePrevious.test.ts | Adds tests for usePrevious |
| src/hooks/tests/useIsFirstRender.test.ts | Adds tests for useIsFirstRender |
| src/hooks/tests/useInterval.test.ts | Adds tests for useInterval |
| .github/instructions/copilot-instructions.md | Updates hook group documentation (currently contains a merge conflict marker) |
| .changeset/COMPT-32-async-lifecycle-hooks.md | Declares a minor release and documents the new hooks |
| import { useRef } from 'react'; | ||
|
|
||
| export function useIsFirstRender(): boolean { | ||
| const isFirstRender = useRef(true); | ||
|
|
||
| // Reading and writing ref.current during render is intentional here: | ||
| // isFirstRender tracks mount state only and never drives output directly. | ||
| /* eslint-disable react-hooks/refs */ | ||
| if (isFirstRender.current) { | ||
| isFirstRender.current = false; | ||
| return true; | ||
| } | ||
| /* eslint-enable react-hooks/refs */ | ||
|
|
||
| return false; |
There was a problem hiding this comment.
This hook mutates ref.current during render. In React 18 StrictMode/concurrent rendering, render can be invoked multiple times before commit; this can cause the initial mount to return false on the second render pass. Prefer setting the ref in an effect (e.g., flip a mounted flag in useEffect([]) / useLayoutEffect([])) and only reading it during render.
| import { useRef } from 'react'; | |
| export function useIsFirstRender(): boolean { | |
| const isFirstRender = useRef(true); | |
| // Reading and writing ref.current during render is intentional here: | |
| // isFirstRender tracks mount state only and never drives output directly. | |
| /* eslint-disable react-hooks/refs */ | |
| if (isFirstRender.current) { | |
| isFirstRender.current = false; | |
| return true; | |
| } | |
| /* eslint-enable react-hooks/refs */ | |
| return false; | |
| import { useEffect, useRef } from 'react'; | |
| export function useIsFirstRender(): boolean { | |
| const isFirstRender = useRef(true); | |
| useEffect(() => { | |
| isFirstRender.current = false; | |
| }, []); | |
| return isFirstRender.current; |
| useEffect(() => { | ||
| if (delay === null) return; | ||
|
|
||
| const id = window.setInterval(() => { | ||
| callbackRef.current(); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| window.clearInterval(id); | ||
| }; |
There was a problem hiding this comment.
useInterval uses window.setInterval/window.clearInterval without a typeof window === 'undefined' guard. Other hooks in this package guard window access (e.g., src/hooks/useWindowSize.ts:16-18), and using window here will throw in non-browser runtimes (React Native, some test runners). Prefer globalThis.setInterval/clearInterval (or setInterval/clearInterval) or add a window guard for consistency.
| const id = window.setTimeout(() => { | ||
| callbackRef.current(); | ||
| }, delay); | ||
|
|
||
| return () => { | ||
| window.clearTimeout(id); |
There was a problem hiding this comment.
useTimeout uses window.setTimeout/window.clearTimeout without a typeof window === 'undefined' guard. Other hooks in this package guard window access (e.g., src/hooks/useWindowSize.ts:16-18), and using window here will throw in non-browser runtimes. Prefer globalThis.setTimeout/clearTimeout (or setTimeout/clearTimeout) or add a window guard for consistency.
| const id = window.setTimeout(() => { | |
| callbackRef.current(); | |
| }, delay); | |
| return () => { | |
| window.clearTimeout(id); | |
| const id = setTimeout(() => { | |
| callbackRef.current(); | |
| }, delay); | |
| return () => { | |
| clearTimeout(id); |
| @@ -0,0 +1,64 @@ | |||
| import { act, renderHook } from '@testing-library/react'; | |||
| import { describe, expect, it } from 'vitest'; | |||
| import { useToggle } from '../useToggle'; | |||
There was a problem hiding this comment.
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.
| import { useToggle } from '../useToggle'; | |
| import { useToggle } from './useToggle'; |
| - **State & Storage** (COMPT-30 ✅) — `useDebounce`, `useLocalStorage`, `useSessionStorage` | ||
| - **DOM & Events** (COMPT-31 ✅) — `useMediaQuery`, `useWindowSize`, `useClickOutside`, `useIntersectionObserver` | ||
| - **Async & Lifecycle** — _(upcoming)_ | ||
| <<<<<<< HEAD |
There was a problem hiding this comment.
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).
| <<<<<<< HEAD |
| import { useState } from 'react'; | ||
|
|
||
| export function usePrevious<T>(value: T): T | undefined { | ||
| const [[prev, curr], setState] = useState<[T | undefined, T]>([undefined, value]); | ||
|
|
||
| if (curr !== value) { | ||
| setState([curr, value]); | ||
| } | ||
|
|
||
| return prev; |
There was a problem hiding this comment.
usePrevious calls setState during render based on curr !== value. This comparison is not safe for all values (e.g., NaN !== NaN), which can trigger an infinite render loop. Consider switching to a ref+effect implementation, or at minimum use Object.is(curr, value) for the comparison to avoid NaN edge cases.
| import { useState } from 'react'; | |
| export function usePrevious<T>(value: T): T | undefined { | |
| const [[prev, curr], setState] = useState<[T | undefined, T]>([undefined, value]); | |
| if (curr !== value) { | |
| setState([curr, value]); | |
| } | |
| return prev; | |
| import { useEffect, useRef } from 'react'; | |
| export function usePrevious<T>(value: T): T | undefined { | |
| const currentRef = useRef<T | undefined>(undefined); | |
| const previousRef = useRef<T | undefined>(undefined); | |
| useEffect(() => { | |
| previousRef.current = currentRef.current; | |
| currentRef.current = value; | |
| }, [value]); | |
| return previousRef.current; |
* initiated dev environment * ops: updated SOnar name * ops (ci): standardize publish validation and dependabot across all packages - Replace git tag --list strategy with package.json-driven tag validation in all 16 publish workflows; use git rev-parse to verify the exact tag exists rather than guessing the latest repo-wide tag - Update error guidance to reflect feat/** → develop → master flow - Standardize dependabot to npm-only, grouped, monthly cadence across all 16 packages; remove github-actions ecosystem updates - Add missing dependabot.yml to AuthKit-UI, ChartKit-UI, HealthKit, HooksKit, paymentkit, StorageKit * chore(COMPT-138): apply repository-wide prettier baseline (#4) - Run prettier --write across repository - No logic/behavior changes - Unblocks CI PR Validation format gate for feature PRs - verify passes: lint + typecheck + tests * Feat/compt 30 state storage hooks (#6) * feat(COMPT-30): add useDebounce, useLocalStorage, useSessionStorage hooks - useDebounce<T>(value, delay): returns debounced value, resets timer on value/delay change - useLocalStorage<T>(key, initial): syncs with localStorage, SSR-safe, JSON serialization - useSessionStorage<T>(key, initial): same pattern for sessionStorage - Shared storage.ts helper with readStorageValue/writeStorageValue (SSR guard + parse fallback) - All three exported from src/hooks/index.ts -> src/index.ts - Full test coverage: timer reset, JSON sync, parse error fallback, SSR guard - tsc --noEmit passes, lint passes (0 warnings), 13/13 tests pass * chore(COMPT-30): add changeset, update copilot-instructions, fix husky pre-commit - .changeset/COMPT-30-state-storage-hooks.md: minor bump summary for 0.0.1 release - .github/instructions/copilot-instructions.md: updated to HooksKit package identity, real src structure with COMPT-30 hooks marked, COMPT-XX branch naming convention - .husky/pre-commit: removed deprecated husky v9 shebang lines (breaks in v10) * Feat/compt 31 dom event hooks (#8) * feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, useIntersectionObserver - useMediaQuery(query): tracks matchMedia via useSyncExternalStore, SSR-safe (server snapshot false) - useWindowSize(): returns {width, height}, debounced 100ms on resize, SSR-safe ({0,0}) - useClickOutside(ref, handler): fires on mousedown/touchstart outside ref; handler via ref pattern - useIntersectionObserver(ref, options?): IntersectionObserverEntry|null, disconnects on unmount - All listeners registered in useEffect with cleanup return - All SSR-safe: typeof window === undefined guards - Zero runtime dependencies - tsc --noEmit passes, lint passes (0 warnings), 26/26 tests pass, coverage >= 95% - All four exported from src/hooks/index.ts -> src/index.ts - Changeset added, copilot-instructions.md updated for epic COMPT-2 * test(COMPT-31): reduce duplicated test blocks for Sonar quality gate - remove accidental duplicated useMediaQuery suite block - extract shared viewport setup in useWindowSize tests - extract shared mount helper in useClickOutside tests - keep behavior coverage unchanged * Feat/compt 32 async lifecycle hooks (#9) * feat(COMPT-32): add usePrevious, useToggle, useInterval, useTimeout, useIsFirstRender - usePrevious<T>(value): previous render value via state-derivation; undefined on first render - useToggle(initial?): boolean toggle with stable useCallback reference - useInterval(callback, delay|null): fires at cadence, stops on null, latest callback via ref - useTimeout(callback, delay|null): fires once, cancels on null or unmount, latest callback via ref - useIsFirstRender(): true only on first render (scoped eslint-disable for intentional ref access) - All timer cleanup in useEffect return — StrictMode safe - Zero runtime deps; tsc --noEmit passes, lint passes, 25/25 tests, hooks coverage >= 98% - All five exported from src/hooks/index.ts -> src/index.ts - Changeset added, copilot-instructions.md updated with all three COMPT groups complete * refactor(COMPT-32): move hook tests to src/hooks/__tests__/ - Moved all 5 hook test files from src/hooks/ to src/hooks/__tests__/ - Updated relative imports from ./hook to ../hook - No logic changes; all 25 tests still pass * test(COMPT-33): consolidate all hook tests into src/hooks/__tests__/ (#10) - Move useDebounce, useLocalStorage, useSessionStorage, useMediaQuery, useWindowSize, useClickOutside, useIntersectionObserver tests to __tests__/ - Update relative imports from ./ to ../ - All 12 hooks covered: fake timers for debounce/interval/timeout/windowSize, mock matchMedia, mock IntersectionObserver, storage parse-error guards - 55 tests passing, 97.44% line coverage (>= 85% AC) * security: added CODEOWNER file for branches security * ops: updated release check workflow * ops: updated relese check workflow# * feat(COMPT-34): README, v0.1.0 version bump, publish preparation (#11) - Full README with installation, SSR note, 12 hook examples with types - Remove __hooks_placeholder from hooks barrel - Fix COMPT-30 changeset package name (reactts-developerkit -> hooks-kit) - Apply changeset version bump: 0.0.0 -> 0.1.0 - Remove duplicate root-level test files (already in __tests__/) - Update copilot-instructions.md with COMPT-34 status Co-authored-by: a-elkhiraooui-ciscode <a.elkhiraoui@ciscod.com> * ci: update release check workflow * ops: updated release check jobs. * v0.1.0 * fix(ci): correct sonar test directory and inclusion patterns --------- Co-authored-by: Zaiidmo <zaiidmoumnii@gmail.com> Co-authored-by: Zaiid Moumni <141942826+Zaiidmo@users.noreply.github.com>



Summary
Why
Checklist
npm run lintpassesnpm run typecheckpassesnpm testpassesnpm run buildpassesnpx changeset) if this affects consumersNotes