Conversation
…ckages - 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
…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 HooksKit, along with accompanying tests and CI/release workflow updates to support publishing and quality checks.
Changes:
- Introduces 4 new SSR-safe hooks:
useMediaQuery,useWindowSize,useClickOutside,useIntersectionObserver(+ tests). - Updates hook exports via
src/hooks/index.tsand package metadata for the renamed HooksKit package. - Adjusts GitHub Actions workflows (release check, publish) and adds Dependabot + a Changeset entry.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/hooks/useWindowSize.ts | New useWindowSize hook + getWindowSize helper |
| src/hooks/useWindowSize.test.ts | Tests for debounced resize + SSR helper behavior |
| src/hooks/useMediaQuery.ts | New useMediaQuery hook using useSyncExternalStore |
| src/hooks/useMediaQuery.test.ts | Tests for useMediaQuery (currently duplicated) |
| src/hooks/useIntersectionObserver.ts | New useIntersectionObserver hook |
| src/hooks/useIntersectionObserver.test.ts | Tests for intersection observer behavior |
| src/hooks/useClickOutside.ts | New useClickOutside hook |
| src/hooks/useClickOutside.test.ts | Tests for click/touch outside + cleanup |
| src/hooks/index.ts | Re-exports new hooks (still includes placeholder export) |
| package.json | Renames package to @ciscode/hooks-kit, adds repository metadata |
| .github/workflows/release-check.yml | Updates CI steps + SonarCloud configuration |
| .github/workflows/publish.yml | Tightens version/tag validation, enables provenance publish |
| .github/workflows/pr-validation.yml | Adds CI workflow for PRs into develop |
| .github/instructions/copilot-instructions.md | Updates project instructions to HooksKit |
| .github/dependabot.yml | Adds Dependabot configuration |
| .changeset/COMPT-31-dom-event-hooks.md | Changeset for new DOM/event hooks release |
| // Example placeholder export — replace with real hooks later. | ||
| export const __hooks_placeholder = true; | ||
|
|
There was a problem hiding this comment.
The __hooks_placeholder export is still present and will be part of the published public API because src/index.ts re-exports ./hooks. Now that real hooks are exported here, the placeholder should be removed to avoid leaking a non-production symbol to consumers.
| // Example placeholder export — replace with real hooks later. | |
| export const __hooks_placeholder = true; |
| -Dsonar.organization=${{ env.SONAR_ORGANIZATION }} | ||
| -Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }} | ||
| -Dsonar.sources=src | ||
| -Dsonar.tests=test |
There was a problem hiding this comment.
-Dsonar.tests=test points to a directory that doesn’t exist in this repo (tests live under src/). This will cause Sonar to miss tests / miscompute coverage. Update sonar.tests (and optionally sonar.test.inclusions) to match the actual test locations.
| -Dsonar.tests=test | |
| -Dsonar.tests=src | |
| -Dsonar.test.inclusions=src/**/*.test.*,src/**/*.spec.* |
|
|
||
| echo "✅ package.json version: $PKG_VERSION" | ||
| echo "✅ Tag $TAG exists in repo" |
There was a problem hiding this comment.
The publish gate only checks that tag v${package.json version} exists somewhere in the repo (git rev-parse), not that it points to the commit being published. This can allow publishing from an untagged commit or reusing an old tag when package.json wasn’t bumped, leading to failed or incorrect publishes. Validate that TAG is an exact match on HEAD (e.g., git describe --exact-match --tags HEAD equals TAG) before proceeding.
| echo "✅ package.json version: $PKG_VERSION" | |
| echo "✅ Tag $TAG exists in repo" | |
| # Ensure that the tag for this version is exactly on the commit being published (HEAD) | |
| HEAD_TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || true) | |
| if [[ -z "$HEAD_TAG" ]]; then | |
| echo "❌ ERROR: HEAD is not tagged." | |
| echo "Expected HEAD to be tagged with $TAG for publishing." | |
| exit 1 | |
| fi | |
| if [[ "$HEAD_TAG" != "$TAG" ]]; then | |
| HEAD_COMMIT=$(git rev-parse HEAD) | |
| TAG_COMMIT=$(git rev-parse "$TAG") | |
| echo "❌ ERROR: Tag mismatch for publish." | |
| echo " package.json version: $PKG_VERSION" | |
| echo " Expected tag on HEAD : $TAG" | |
| echo " Actual tag on HEAD : $HEAD_TAG" | |
| echo " HEAD commit : $HEAD_COMMIT" | |
| echo " $TAG commit : $TAG_COMMIT" | |
| echo "" | |
| echo "Ensure you are publishing from the commit tagged with $TAG." | |
| exit 1 | |
| fi | |
| echo "✅ package.json version: $PKG_VERSION" | |
| echo "✅ Tag $TAG exists in repo" | |
| echo "✅ Tag $TAG matches HEAD" |
| if (typeof window === 'undefined') return; | ||
|
|
||
| const handleEvent = (event: MouseEvent | TouchEvent) => { | ||
| if (ref.current && !ref.current.contains(event.target as Node)) { |
There was a problem hiding this comment.
event.target is cast to Node without checking. For events targeting window or other non-Node targets, ref.current.contains(...) can throw at runtime. Guard with event.target instanceof Node (and handle null) before calling contains.
| if (ref.current && !ref.current.contains(event.target as Node)) { | |
| if (!ref.current) { | |
| return; | |
| } | |
| const target = event.target; | |
| if (target instanceof Node && !ref.current.contains(target)) { |
| - name: SonarCloud Scan | ||
| if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.sonar == 'true' }} | ||
| uses: SonarSource/sonarqube-scan-action@v6 | ||
| env: | ||
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | ||
| SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} | ||
| with: |
There was a problem hiding this comment.
SonarCloud steps run unconditionally on pull_request. For PRs from forks, secrets.SONAR_TOKEN is not available, so the workflow will fail. Consider gating these steps (e.g., if: github.event.pull_request.head.repo.fork == false) or using a safe alternative like pull_request_target with appropriate hardening.
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| uses: actions/checkout@v6 |
There was a problem hiding this comment.
This workflow uses actions/checkout@v6 while the rest of the repo uses @v4. Align on a single version and verify the chosen major version is valid/supported to avoid CI breakage due to an invalid action ref.
| uses: actions/checkout@v6 | |
| uses: actions/checkout@v4 |
| let timeoutId: ReturnType<typeof window.setTimeout>; | ||
|
|
||
| const handleResize = () => { | ||
| window.clearTimeout(timeoutId); | ||
| timeoutId = window.setTimeout(() => { | ||
| setSize(getWindowSize()); | ||
| }, 100); |
There was a problem hiding this comment.
timeoutId is cleared before it has definitely been assigned (first resize event). With strict: true, this typically triggers TS2454 (used before assigned) and can also be confusing at runtime. Initialize it (e.g., to undefined) and only clear when defined, or assign a default timer id up front.
| height: number; | ||
| } | ||
|
|
||
| export function getWindowSize(): WindowSize { |
There was a problem hiding this comment.
getWindowSize is exported and will become part of the library’s public API via the hooks barrel. If it’s intended as an internal helper, make it non-exported and/or avoid re-exporting it to prevent an unintended surface-area increase.
| export function getWindowSize(): WindowSize { | |
| function getWindowSize(): WindowSize { |
| 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.
optionsRef is initialized once and never updated, so changing options after the first render has no effect. Also, the effect only depends on [ref], so observer configuration won’t be recreated when options changes. Either include options in the effect dependencies (and recreate the observer) or add an effect to keep optionsRef.current in sync.
| 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]); |
| 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 contains two identical describe('useMediaQuery', ...) blocks, so every test is duplicated and will run twice. Remove the duplicated block to avoid redundant runs and confusion when interpreting failures.
| 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); | |
| }); | |
| }); |


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