Feat/compt 31 dom event hooks#8
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
- remove accidental duplicated useMediaQuery suite block - extract shared viewport setup in useWindowSize tests - extract shared mount helper in useClickOutside tests - keep behavior coverage unchanged
|
There was a problem hiding this comment.
Pull request overview
Adds a new “DOM & Events” hook group to HooksKit (COMPT-31), expanding the library’s hook surface area alongside accompanying tests, docs, and a changeset.
Changes:
- Introduces four new hooks:
useMediaQuery,useWindowSize,useClickOutside,useIntersectionObserver. - Adds Vitest + React Testing Library coverage for each new hook.
- Updates the hooks barrel export list, project instructions, and adds a minor-version changeset.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useWindowSize.ts | Adds debounced resize-based window size hook (and a helper). |
| src/hooks/useWindowSize.test.ts | Tests initial size + debounced resize behavior + cleanup. |
| src/hooks/useMediaQuery.ts | Adds matchMedia-backed query hook using useSyncExternalStore. |
| src/hooks/useMediaQuery.test.ts | Tests initial match, updates, and unmount cleanup. |
| src/hooks/useIntersectionObserver.ts | Adds IntersectionObserver-backed hook for latest entry. |
| src/hooks/useIntersectionObserver.test.ts | Tests entry updates, observe/disconnect behavior. |
| src/hooks/useClickOutside.ts | Adds outside-click/touch handler hook with stale-closure avoidance. |
| src/hooks/useClickOutside.test.ts | Tests outside vs inside interactions and cleanup. |
| src/hooks/index.ts | Exports the new hooks from the hooks barrel. |
| .github/instructions/copilot-instructions.md | Updates docs to include COMPT-31 hooks and library naming/structure. |
| .changeset/COMPT-31-dom-event-hooks.md | Declares a minor release and documents the new hooks. |
| height: number; | ||
| } | ||
|
|
||
| export function getWindowSize(): WindowSize { |
There was a problem hiding this comment.
getWindowSize is exported from this module, which makes it part of the public API via the hook barrel exports. If the intent is to expose only hooks, make this helper const/non-exported (or move it to an internal util) to avoid an unintended API surface and future breaking-change constraints.
| export function getWindowSize(): WindowSize { | |
| function getWindowSize(): WindowSize { |
| 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.
The effect captures the initial options and observes only the initial ref.current. If options change or the ref starts pointing to a different element after mount, the observer won’t be recreated/re-attached, so the hook can return stale results. Consider tracking the observed element (e.g., const node = ref.current) and including node + options in the effect dependencies (or updating optionsRef.current when options changes).
| 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]); | |
| useEffect(() => { | |
| if (typeof window === 'undefined') return; | |
| const node = ref.current; | |
| if (!node) return; | |
| const observer = new IntersectionObserver(([newEntry]) => { | |
| if (newEntry) setEntry(newEntry); | |
| }, options); | |
| observer.observe(node); | |
| return () => { | |
| observer.disconnect(); | |
| }; | |
| }, [ref.current, options]); |
| 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.
new IntersectionObserver(...) will throw in environments where the API is unavailable (older browsers, some test/embedded webviews). Add a feature-detection guard (e.g., check typeof IntersectionObserver !== 'undefined') and return null / no-op when unsupported to keep the hook safe to import/use.
|
|
||
| useEffect(() => { | ||
| handlerRef.current = handler; | ||
| }); |
There was a problem hiding this comment.
This effect runs after every render because it has no dependency array. Since it only needs to update the ref when handler changes, add [handler] as the dependency list to avoid unnecessary effect executions.
| }); | |
| }, [handler]); |
| 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.
window.matchMedia isn’t available in all browser-like environments (and is undefined in some DOM shims unless polyfilled). As written, calling useMediaQuery will throw if matchMedia is missing. Consider guarding for typeof window === 'undefined' || typeof window.matchMedia !== 'function' and falling back to false with a no-op subscription.
| 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, |



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