Skip to content

Feat/compt 31 dom event hooks#2

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

Feat/compt 31 dom event hooks#2
a-elkhiraooui-ciscode wants to merge 4 commits intomasterfrom
feat/COMPT-31-dom-event-hooks

Conversation

@a-elkhiraooui-ciscode
Copy link
Copy Markdown

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?

Zaiidmo and others added 4 commits March 29, 2026 12:50
…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
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
15.2% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

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 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.ts and 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

Comment on lines 1 to 3
// Example placeholder export — replace with real hooks later.
export const __hooks_placeholder = true;

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

Suggested change
// Example placeholder export — replace with real hooks later.
export const __hooks_placeholder = true;

Copilot uses AI. Check for mistakes.
-Dsonar.organization=${{ env.SONAR_ORGANIZATION }}
-Dsonar.projectKey=${{ env.SONAR_PROJECT_KEY }}
-Dsonar.sources=src
-Dsonar.tests=test
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.

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

Suggested change
-Dsonar.tests=test
-Dsonar.tests=src
-Dsonar.test.inclusions=src/**/*.test.*,src/**/*.spec.*

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +57

echo "✅ package.json version: $PKG_VERSION"
echo "✅ Tag $TAG exists in repo"
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 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.

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

Copilot uses AI. Check for mistakes.
if (typeof window === 'undefined') return;

const handleEvent = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
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.

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.

Suggested change
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)) {

Copilot uses AI. Check for mistakes.
Comment on lines 55 to 60
- 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:
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.

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.

Copilot uses AI. Check for mistakes.
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
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 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.

Suggested change
uses: actions/checkout@v6
uses: actions/checkout@v4

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +25
let timeoutId: ReturnType<typeof window.setTimeout>;

const handleResize = () => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
setSize(getWindowSize());
}, 100);
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.

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.

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

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

Copilot uses AI. Check for mistakes.
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.

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.

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 +91 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 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.

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

3 participants