Skip to content

feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, us…#7

Closed
a-elkhiraooui-ciscode wants to merge 2 commits intodevelopfrom
feat/COMPT-31-dom-event-hooks
Closed

feat(COMPT-31): add useMediaQuery, useWindowSize, useClickOutside, us…#7
a-elkhiraooui-ciscode wants to merge 2 commits intodevelopfrom
feat/COMPT-31-dom-event-hooks

Conversation

@a-elkhiraooui-ciscode
Copy link
Copy Markdown

…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

Summary

  • What does this PR change?

Why

  • Why is this change needed?

Checklist

  • Added/updated tests (if behavior changed)
  • npm run lint passes
  • npm run typecheck passes
  • npm test passes
  • npm run build passes
  • Added a changeset (npx changeset) if this affects consumers

Notes

  • Anything reviewers should pay attention to?

…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
Copilot AI review requested due to automatic review settings March 30, 2026 12:56
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1 to +21
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]);
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.

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.

Suggested change
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]);

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +11
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),
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.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +11
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),
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.

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +81
it('returns false as SSR-safe default (typeof window === undefined)', () => {
vi.stubGlobal('window', undefined);

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

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +97 to +99
it('returns null in SSR context (typeof window === undefined)', () => {
vi.stubGlobal('window', undefined);

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

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

Suggested change
- SSR-safe (`typeof window === 'undefined'` guards in every hook)
- SSR-safe by design (use `typeof window === 'undefined'` guards around browser-only APIs)

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +12
useEffect(() => {
if (typeof window === 'undefined' || !ref.current) return;

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.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +15
if (typeof window === 'undefined' || !ref.current) return;

const observer = new IntersectionObserver(([newEntry]) => {
if (newEntry) setEntry(newEntry);
}, optionsRef.current);
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 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.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +140

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);
});
});
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 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).

Suggested change
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);
});
});

Copilot uses AI. Check for mistakes.
height: number;
}

export function getWindowSize(): WindowSize {
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.

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.

Suggested change
export function getWindowSize(): WindowSize {
function getWindowSize(): WindowSize {

Copilot uses AI. Check for mistakes.
- remove accidental duplicated useMediaQuery suite block
- extract shared viewport setup in useWindowSize tests
- extract shared mount helper in useClickOutside tests
- keep behavior coverage unchanged
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants