Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
139 changes: 137 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@happy-dom/global-registrator": "^20.6.1",
"@playwright/test": "^1.49.1",
"@storybook/react": "^8.4.7",
"@storybook/react-vite": "^8.4.7",
Expand All @@ -149,6 +150,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"happy-dom": "^20.6.1",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
Expand Down
165 changes: 165 additions & 0 deletions src/hooks/use-mobile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();

import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test";
import { renderHook, act } from "@testing-library/react";
import { useIsMobile } from "./use-mobile";

// The hook uses 768 as the breakpoint internally.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const MOBILE_BREAKPOINT = 768;
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

Unused variable MOBILE_BREAKPOINT.

Copilot uses AI. Check for mistakes.

describe("useIsMobile", () => {
let listeners: Record<string, ((e: MediaQueryListEvent) => void)[]> = {};

// Store original implementation to restore later
let originalMatchMedia: any;
let originalInnerWidth: number;

beforeEach(() => {
listeners = {};

// Store original implementation
originalMatchMedia = window.matchMedia;
originalInnerWidth = window.innerWidth;

// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: mock((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: mock(), // Deprecated
removeListener: mock(), // Deprecated
addEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (!listeners[type]) {
listeners[type] = [];
}
listeners[type].push(listener);
}),
removeEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (listeners[type]) {
listeners[type] = listeners[type].filter((l) => l !== listener);
}
}),
dispatchEvent: mock(),
})),
Comment on lines +29 to +47
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The mock implementation of matchMedia always returns matches: false regardless of the query or window size. This doesn't accurately reflect how matchMedia works. The matches property should be dynamically computed based on the query and current window.innerWidth. For example, when mocking a query like "(max-width: 767px)" and window.innerWidth is 500, the matches property should be true.

Suggested change
value: mock((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: mock(), // Deprecated
removeListener: mock(), // Deprecated
addEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(listener);
}),
removeEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (listeners[type]) {
listeners[type] = listeners[type].filter((l) => l !== listener);
}
}),
dispatchEvent: mock(),
})),
value: mock((query: string) => {
const width = window.innerWidth;
// Basic support for (max-width: Npx), (min-width: Npx) and combined queries
const maxMatch = query.match(/\(max-width:\s*(\d+)px\)/);
const minMatch = query.match(/\(min-width:\s*(\d+)px\)/);
const maxWidth = maxMatch ? parseInt(maxMatch[1], 10) : undefined;
const minWidth = minMatch ? parseInt(minMatch[1], 10) : undefined;
let matches = true;
if (typeof maxWidth === "number") {
matches = matches && width <= maxWidth;
}
if (typeof minWidth === "number") {
matches = matches && width >= minWidth;
}
// If no constraints parsed, default to false to avoid accidental matches
if (typeof maxWidth === "undefined" && typeof minWidth === "undefined") {
matches = false;
}
return {
matches,
media: query,
onchange: null,
addListener: mock(), // Deprecated
removeListener: mock(), // Deprecated
addEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (!listeners[type]) listeners[type] = [];
listeners[type].push(listener);
}),
removeEventListener: mock((type: string, listener: (e: MediaQueryListEvent) => void) => {
if (listeners[type]) {
listeners[type] = listeners[type].filter((l) => l !== listener);
}
}),
dispatchEvent: mock(),
};
}),

Copilot uses AI. Check for mistakes.
});

// Mock window.innerWidth
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024, // Default to desktop
});
});

afterEach(() => {
if (originalMatchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: originalMatchMedia,
});
}

if (originalInnerWidth !== undefined) {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: originalInnerWidth,
});
}
mock.restore();
});

test("should return false when window width is greater than MOBILE_BREAKPOINT", () => {
// Set width to desktop size
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});

const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});

test("should return true when window width is less than MOBILE_BREAKPOINT", () => {
// Set width to mobile size
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 500,
});

const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});

Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

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

The test suite is missing edge case tests for the exact breakpoint boundaries. The hook checks window.innerWidth < 768, so values of 767px (should be mobile) and 768px (should be desktop) should be explicitly tested to ensure the boundary condition is handled correctly.

Suggested change
test("should return true when window width is exactly one pixel below MOBILE_BREAKPOINT", () => {
// Set width to just below the breakpoint (mobile)
Object.defineProperty(window, "innerWidth", {
writable: true,
value: MOBILE_BREAKPOINT - 1,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
test("should return false when window width is exactly MOBILE_BREAKPOINT", () => {
// Set width to the breakpoint (desktop according to '< MOBILE_BREAKPOINT')
Object.defineProperty(window, "innerWidth", {
writable: true,
value: MOBILE_BREAKPOINT,
});
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});

Copilot uses AI. Check for mistakes.
test("should update value when window resizes to mobile", () => {
// Start with desktop
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});

const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);

// Simulate resize to mobile
act(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 500,
});

// Trigger the 'change' event on the media query list
// The hook listens to 'change' event
if (listeners["change"]) {
listeners["change"].forEach((listener) =>
listener({ matches: true } as MediaQueryListEvent)
);
}
});

expect(result.current).toBe(true);
});

test("should update value when window resizes to desktop", () => {
// Start with mobile
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 500,
});

const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);

// Simulate resize to desktop
act(() => {
Object.defineProperty(window, "innerWidth", {
writable: true,
value: 1024,
});

// Trigger the 'change' event
if (listeners["change"]) {
listeners["change"].forEach((listener) =>
listener({ matches: false } as MediaQueryListEvent)
);
}
});

expect(result.current).toBe(false);
});

test("should cleanup event listener on unmount", () => {
const { unmount } = renderHook(() => useIsMobile());

// Get the mock instance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mqlMock = (window.matchMedia as any).mock.results[0].value;

unmount();

expect(mqlMock.removeEventListener).toHaveBeenCalledTimes(1);
expect(mqlMock.removeEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});
Loading