feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, us…#7
feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, us…#7a-elkhiraooui-ciscode wants to merge 2 commits intodevelopfrom
Conversation
…eIntersectionObserver
- 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
There was a problem hiding this comment.
Pull request overview
Adds the COMPT-31 “DOM & Events” hook set to the HooksKit library, along with tests, documentation updates, and a changeset for consumer-facing release.
Changes:
- Introduces 4 new hooks:
useMediaQuery,useWindowSize,useClickOutside,useIntersectionObserver. - Adds Vitest + React Testing Library coverage for the new hooks.
- Exports the new hooks from the hooks barrel and updates project instructions + changeset metadata.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
src/hooks/useMediaQuery.ts |
New hook using useSyncExternalStore to track matchMedia. |
src/hooks/useMediaQuery.test.ts |
Tests for media query behavior (currently contains duplicated suite + weak SSR test). |
src/hooks/useWindowSize.ts |
New debounced window resize hook (currently also exports getWindowSize publicly). |
src/hooks/useWindowSize.test.ts |
Tests for resize + debounce behavior and SSR-safe helper behavior. |
src/hooks/useClickOutside.ts |
New outside-click handler hook using document listeners with cleanup. |
src/hooks/useClickOutside.test.ts |
Tests for outside/inside interactions and listener cleanup. |
src/hooks/useIntersectionObserver.ts |
New intersection observer hook (current implementation has attachment/options update issues). |
src/hooks/useIntersectionObserver.test.ts |
Tests for observer wiring + cleanup (SSR test currently not exercising the hook). |
src/hooks/index.ts |
Re-exports the new hooks from the hooks barrel. |
.github/instructions/copilot-instructions.md |
Updates module docs for COMPT-31 and revises SSR/testing statements (one statement currently contradicts code). |
.changeset/COMPT-31-dom-event-hooks.md |
Declares a minor release and documents the new hooks. |
| import { type RefObject, useEffect, useRef, useState } from 'react'; | ||
|
|
||
| export function useIntersectionObserver( | ||
| ref: RefObject<Element | null>, | ||
| options?: IntersectionObserverInit, | ||
| ): IntersectionObserverEntry | null { | ||
| const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null); | ||
| const optionsRef = useRef(options); | ||
|
|
||
| useEffect(() => { | ||
| if (typeof window === 'undefined' || !ref.current) return; | ||
|
|
||
| const observer = new IntersectionObserver(([newEntry]) => { | ||
| if (newEntry) setEntry(newEntry); | ||
| }, optionsRef.current); | ||
|
|
||
| observer.observe(ref.current); | ||
| return () => { | ||
| observer.disconnect(); | ||
| }; | ||
| }, [ref]); |
There was a problem hiding this comment.
options changes are ignored: the observer is created with optionsRef.current (initialized once) and the effect does not re-run when options changes. Either document options as static, or include options in the effect and recreate the observer accordingly.
| import { type RefObject, useEffect, useRef, useState } from 'react'; | |
| export function useIntersectionObserver( | |
| ref: RefObject<Element | null>, | |
| options?: IntersectionObserverInit, | |
| ): IntersectionObserverEntry | null { | |
| const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null); | |
| const optionsRef = useRef(options); | |
| useEffect(() => { | |
| if (typeof window === 'undefined' || !ref.current) return; | |
| const observer = new IntersectionObserver(([newEntry]) => { | |
| if (newEntry) setEntry(newEntry); | |
| }, optionsRef.current); | |
| observer.observe(ref.current); | |
| return () => { | |
| observer.disconnect(); | |
| }; | |
| }, [ref]); | |
| import { type RefObject, useEffect, useState } from 'react'; | |
| export function useIntersectionObserver( | |
| ref: RefObject<Element | null>, | |
| options?: IntersectionObserverInit, | |
| ): IntersectionObserverEntry | null { | |
| const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null); | |
| useEffect(() => { | |
| if (typeof window === 'undefined' || !ref.current) return; | |
| const observer = new IntersectionObserver(([newEntry]) => { | |
| if (newEntry) setEntry(newEntry); | |
| }, options); | |
| observer.observe(ref.current); | |
| return () => { | |
| observer.disconnect(); | |
| }; | |
| }, [ref, options]); |
| import { useSyncExternalStore } from 'react'; | ||
|
|
||
| export function useMediaQuery(query: string): boolean { | ||
| return useSyncExternalStore( | ||
| (callback) => { | ||
| if (typeof window === 'undefined') return () => undefined; | ||
| const mql = window.matchMedia(query); | ||
| mql.addEventListener('change', callback); | ||
| return () => mql.removeEventListener('change', callback); | ||
| }, | ||
| () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), |
There was a problem hiding this comment.
useSyncExternalStore is passed inline subscribe/snapshot lambdas, which are re-created every render and can cause unnecessary unsubscribe/subscribe churn. Memoize these functions (e.g., useCallback) keyed by query to keep subscriptions stable.
| import { useSyncExternalStore } from 'react'; | |
| export function useMediaQuery(query: string): boolean { | |
| return useSyncExternalStore( | |
| (callback) => { | |
| if (typeof window === 'undefined') return () => undefined; | |
| const mql = window.matchMedia(query); | |
| mql.addEventListener('change', callback); | |
| return () => mql.removeEventListener('change', callback); | |
| }, | |
| () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), | |
| import { useCallback, useSyncExternalStore } from 'react'; | |
| export function useMediaQuery(query: string): boolean { | |
| const subscribe = useCallback( | |
| (callback: () => void) => { | |
| if (typeof window === 'undefined') { | |
| return () => undefined; | |
| } | |
| const mql = window.matchMedia(query); | |
| mql.addEventListener('change', callback); | |
| return () => mql.removeEventListener('change', callback); | |
| }, | |
| [query], | |
| ); | |
| const getSnapshot = useCallback( | |
| () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), | |
| [query], | |
| ); | |
| return useSyncExternalStore( | |
| subscribe, | |
| getSnapshot, |
| if (typeof window === 'undefined') return () => undefined; | ||
| const mql = window.matchMedia(query); | ||
| mql.addEventListener('change', callback); | ||
| return () => mql.removeEventListener('change', callback); | ||
| }, | ||
| () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), |
There was a problem hiding this comment.
If window.matchMedia is not available (common in some environments), this will throw. Consider falling back to false when typeof window.matchMedia !== 'function' and making subscribe a no-op in that case.
| if (typeof window === 'undefined') return () => undefined; | |
| const mql = window.matchMedia(query); | |
| mql.addEventListener('change', callback); | |
| return () => mql.removeEventListener('change', callback); | |
| }, | |
| () => (typeof window !== 'undefined' ? window.matchMedia(query).matches : false), | |
| if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { | |
| return () => undefined; | |
| } | |
| const mql = window.matchMedia(query); | |
| mql.addEventListener('change', callback); | |
| return () => mql.removeEventListener('change', callback); | |
| }, | |
| () => | |
| typeof window !== 'undefined' && typeof window.matchMedia === 'function' | |
| ? window.matchMedia(query).matches | |
| : false, |
src/hooks/useMediaQuery.test.ts
Outdated
| it('returns false as SSR-safe default (typeof window === undefined)', () => { | ||
| vi.stubGlobal('window', undefined); | ||
|
|
There was a problem hiding this comment.
The “SSR-safe default” test doesn’t exercise useMediaQuery (it only asserts a local helper function returns false). Add a test that the hook itself returns false / does not throw when window or matchMedia are unavailable.
| it('returns null in SSR context (typeof window === undefined)', () => { | ||
| vi.stubGlobal('window', undefined); | ||
|
|
There was a problem hiding this comment.
This “SSR context” test doesn’t exercise useIntersectionObserver (it asserts a local helper that always returns null). Add a test that the hook itself can be invoked without throwing when window/IntersectionObserver are unavailable, and returns null in that case.
| - 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) | ||
| - SSR-safe (`typeof window === 'undefined'` guards in every hook) |
There was a problem hiding this comment.
This document now states SSR-safety is ensured via typeof window === 'undefined' guards “in every hook”, but useDebounce currently calls window.setTimeout directly without a guard. Either update useDebounce to be SSR-safe or soften this statement to avoid incorrect guidance.
| - SSR-safe (`typeof window === 'undefined'` guards in every hook) | |
| - SSR-safe by design (use `typeof window === 'undefined'` guards around browser-only APIs) |
| useEffect(() => { | ||
| if (typeof window === 'undefined' || !ref.current) return; | ||
|
|
There was a problem hiding this comment.
Because the effect returns early when ref.current is falsy, and the dependency array is [ref], the observer may never attach in cases where the element appears later (e.g., conditional rendering that sets ref.current on a subsequent render). Consider a callback-ref / Element | null parameter, or otherwise re-running the effect when the observed node changes.
| if (typeof window === 'undefined' || !ref.current) return; | ||
|
|
||
| const observer = new IntersectionObserver(([newEntry]) => { | ||
| if (newEntry) setEntry(newEntry); | ||
| }, optionsRef.current); |
There was a problem hiding this comment.
This will throw at runtime in environments where IntersectionObserver is not available (e.g., older browsers or certain test setups). Consider guarding typeof IntersectionObserver === 'undefined' (or 'IntersectionObserver' in window) and returning null without creating an observer.
src/hooks/useMediaQuery.test.ts
Outdated
|
|
||
| describe('useMediaQuery', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it('returns true when query initially matches', () => { | ||
| mockMatchMedia(true); | ||
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | ||
| expect(result.current).toBe(true); | ||
| }); | ||
|
|
||
| it('returns false when query does not initially match', () => { | ||
| mockMatchMedia(false); | ||
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | ||
| expect(result.current).toBe(false); | ||
| }); | ||
|
|
||
| it('updates when media query match changes', () => { | ||
| const { triggerChange } = mockMatchMedia(false); | ||
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | ||
|
|
||
| expect(result.current).toBe(false); | ||
|
|
||
| act(() => { | ||
| triggerChange(true); | ||
| }); | ||
|
|
||
| expect(result.current).toBe(true); | ||
| }); | ||
|
|
||
| it('removes event listener on unmount', () => { | ||
| const { mql } = mockMatchMedia(true); | ||
| const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')); | ||
|
|
||
| unmount(); | ||
|
|
||
| expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); | ||
| }); | ||
|
|
||
| it('returns false as SSR-safe default (typeof window === undefined)', () => { | ||
| vi.stubGlobal('window', undefined); | ||
|
|
||
| const getDefault = () => { | ||
| if (typeof window === 'undefined') return false; | ||
| return window.matchMedia('(min-width: 768px)').matches; | ||
| }; | ||
|
|
||
| expect(getDefault()).toBe(false); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
This file has a second describe('useMediaQuery', ...) block repeating the same test cases, so the suite runs twice and increases runtime/noise. Remove the duplicate block (or merge if they were meant to diverge).
| describe('useMediaQuery', () => { | |
| afterEach(() => { | |
| vi.unstubAllGlobals(); | |
| }); | |
| it('returns true when query initially matches', () => { | |
| mockMatchMedia(true); | |
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | |
| expect(result.current).toBe(true); | |
| }); | |
| it('returns false when query does not initially match', () => { | |
| mockMatchMedia(false); | |
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | |
| expect(result.current).toBe(false); | |
| }); | |
| it('updates when media query match changes', () => { | |
| const { triggerChange } = mockMatchMedia(false); | |
| const { result } = renderHook(() => useMediaQuery('(min-width: 768px)')); | |
| expect(result.current).toBe(false); | |
| act(() => { | |
| triggerChange(true); | |
| }); | |
| expect(result.current).toBe(true); | |
| }); | |
| it('removes event listener on unmount', () => { | |
| const { mql } = mockMatchMedia(true); | |
| const { unmount } = renderHook(() => useMediaQuery('(min-width: 768px)')); | |
| unmount(); | |
| expect(mql.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); | |
| }); | |
| it('returns false as SSR-safe default (typeof window === undefined)', () => { | |
| vi.stubGlobal('window', undefined); | |
| const getDefault = () => { | |
| if (typeof window === 'undefined') return false; | |
| return window.matchMedia('(min-width: 768px)').matches; | |
| }; | |
| expect(getDefault()).toBe(false); | |
| }); | |
| }); |
| height: number; | ||
| } | ||
|
|
||
| export function getWindowSize(): WindowSize { |
There was a problem hiding this comment.
getWindowSize is exported from this module, and because src/hooks/index.ts re-exports everything, it becomes part of the library’s public API. If it’s only intended as an internal helper for the hook/tests, consider making it non-exported (and testing via the hook) to avoid expanding the public surface area unintentionally.
| export function getWindowSize(): WindowSize { | |
| function getWindowSize(): WindowSize { |
- remove accidental duplicated useMediaQuery suite block - extract shared viewport setup in useWindowSize tests - extract shared mount helper in useClickOutside tests - keep behavior coverage unchanged
|



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