diff --git a/src/useMediaQuery/index.dom.test.ts b/src/useMediaQuery/index.dom.test.ts index 4fa343a3..59f9f3bb 100644 --- a/src/useMediaQuery/index.dom.test.ts +++ b/src/useMediaQuery/index.dom.test.ts @@ -1,16 +1,24 @@ import {act, renderHook} from '@testing-library/react-hooks/dom'; -import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {type Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {useMediaQuery} from '../index.js'; describe('useMediaQuery', () => { - const matchMediaMock = vi.fn((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + const matchMediaMock = vi.fn((query: string) => ( + query === '(orientation: unsupported)' ? + undefined : + { + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as unknown as MediaQueryList & { + matches: boolean; + addEventListener: Mock; + removeEventListener: Mock; + dispatchEvent: Mock; + }); vi.stubGlobal('matchMedia', matchMediaMock); @@ -30,6 +38,34 @@ describe('useMediaQuery', () => { expect(result.error).toBeUndefined(); }); + it('should return undefined and not thrown on unsupported when not enabled', () => { + vi.stubGlobal('console', { + error(error: string) { + throw new Error(error); + }, + }); + const {result, rerender, unmount} = renderHook(() => useMediaQuery('max-width : 768px', {enabled: false})); + const {result: result2, rerender: rerender2, unmount: unmount2} = renderHook(() => useMediaQuery('(orientation: unsupported)', {enabled: false})); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + expect(result2.error).toBeUndefined(); + expect(result2.current).toBe(undefined); + rerender('max-width : 768px'); + rerender2('(orientation: unsupported)'); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + expect(result2.current).toBe(undefined); + expect(result2.error).toBeUndefined(); + unmount(); + unmount2(); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + expect(result2.error).toBeUndefined(); + expect(result2.current).toBe(undefined); + vi.unstubAllGlobals(); + vi.stubGlobal('matchMedia', matchMediaMock); + }); + it('should return undefined on first render, if initializeWithValue is false', () => { const {result} = renderHook(() => useMediaQuery('max-width : 768px', {initializeWithValue: false})); @@ -147,4 +183,16 @@ describe('useMediaQuery', () => { unmount1(); expect(mql.removeEventListener).toHaveBeenCalledTimes(1); }); + + it('should not throw when media query is not supported', () => { + const {result, unmount, rerender} = renderHook(() => useMediaQuery('(orientation: unsupported)', {initializeWithValue: true})); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + rerender(); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + unmount(); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + }); }); diff --git a/src/useMediaQuery/index.ssr.test.ts b/src/useMediaQuery/index.ssr.test.ts index d2e05c9c..c7fe55fd 100644 --- a/src/useMediaQuery/index.ssr.test.ts +++ b/src/useMediaQuery/index.ssr.test.ts @@ -18,4 +18,11 @@ describe('useMediaQuery', () => { useMediaQuery('max-width : 768px', {initializeWithValue: false})); expect(result.current).toBeUndefined(); }); + + it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => { + const {result} = renderHook(() => + useMediaQuery('max-width : 768px', {initializeWithValue: true, enabled: false})); + expect(result.error).toBeUndefined(); + expect(result.current).toBeUndefined(); + }); }); diff --git a/src/useMediaQuery/index.ts b/src/useMediaQuery/index.ts index 754137f8..7fd26fb0 100644 --- a/src/useMediaQuery/index.ts +++ b/src/useMediaQuery/index.ts @@ -3,13 +3,43 @@ import {isBrowser} from '../util/const.js'; const queriesMap = new Map< string, - {mql: MediaQueryList; dispatchers: Set>; listener: () => void} + { + mql: MediaQueryList; + dispatchers: Set>; + listener: () => void; + } >(); type QueryStateSetter = (matches: boolean) => void; const createQueryEntry = (query: string) => { const mql = matchMedia(query); + if (!mql) { + if ( + typeof process === 'undefined' || + process.env === undefined || + process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'test' + ) { + console.error(`error: matchMedia('${query}') returned null, this means that the browser does not support this query or the query is invalid.`); + } + + return { + mql: { + onchange: null, + matches: undefined as unknown as boolean, + media: query, + addEventListener: () => undefined as void, + addListener: () => undefined as void, + removeListener: () => undefined as void, + removeEventListener: () => undefined as void, + dispatchEvent: () => false as boolean, + }, + dispatchers: new Set>(), + listener: () => undefined as void, + }; + } + const dispatchers = new Set(); const listener = () => { for (const d of dispatchers) { @@ -61,6 +91,7 @@ const queryUnsubscribe = (query: string, setState: QueryStateSetter): void => { type UseMediaQueryOptions = { initializeWithValue?: boolean; + enabled?: boolean; }; /** @@ -70,19 +101,20 @@ type UseMediaQueryOptions = { * @param options Hook options: * `initializeWithValue` (default: `true`) - Determine media query match state on first render. Setting * this to false will make the hook yield `undefined` on first render. + * `enabled` (default: `true`) - Enable or disable the hook. */ export function useMediaQuery( query: string, options: UseMediaQueryOptions = {}, ): boolean | undefined { - let {initializeWithValue = true} = options; + let {initializeWithValue = true, enabled = true} = options; if (!isBrowser) { initializeWithValue = false; } const [state, setState] = useState(() => { - if (initializeWithValue) { + if (initializeWithValue && enabled) { let entry = queriesMap.get(query); if (!entry) { entry = createQueryEntry(query); @@ -94,12 +126,16 @@ export function useMediaQuery( }); useEffect(() => { + if (!enabled) { + return; + } + querySubscribe(query, setState); return () => { queryUnsubscribe(query, setState); }; - }, [query]); + }, [query, enabled]); return state; } diff --git a/src/useScreenOrientation/index.ssr.test.ts b/src/useScreenOrientation/index.ssr.test.ts index b87eb382..776a915a 100644 --- a/src/useScreenOrientation/index.ssr.test.ts +++ b/src/useScreenOrientation/index.ssr.test.ts @@ -11,4 +11,11 @@ describe('useScreenOrientation', () => { const {result} = renderHook(() => useScreenOrientation({initializeWithValue: false})); expect(result.error).toBeUndefined(); }); + + it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => { + const {result} = renderHook(() => + useScreenOrientation({initializeWithValue: true, enabled: false})); + expect(result.error).toBeUndefined(); + expect(result.current).toBeUndefined(); + }); }); diff --git a/src/useScreenOrientation/index.ts b/src/useScreenOrientation/index.ts index 342b14bc..d46a387c 100644 --- a/src/useScreenOrientation/index.ts +++ b/src/useScreenOrientation/index.ts @@ -4,6 +4,7 @@ export type ScreenOrientation = 'portrait' | 'landscape'; type UseScreenOrientationOptions = { initializeWithValue?: boolean; + enabled?: boolean; }; /** @@ -11,12 +12,17 @@ type UseScreenOrientationOptions = { * * As `Screen Orientation API` is still experimental and not supported by Safari, this * hook uses CSS3 `orientation` media-query to check screen orientation. + * @param options Hook options: + * `initializeWithValue` (default: `true`) - Determine screen orientation on first render. Setting + * this to false will make the hook yield `undefined` on first render. + * `enabled` (default: `true`) - Enable or disable the hook. */ export function useScreenOrientation( options?: UseScreenOrientationOptions, ): ScreenOrientation | undefined { const matches = useMediaQuery('(orientation: portrait)', { initializeWithValue: options?.initializeWithValue ?? true, + enabled: options?.enabled, }); return matches === undefined ? undefined : (matches ? 'portrait' : 'landscape');