From 3f2db52b94c42532199ac0db1f5813c5db7ab21f Mon Sep 17 00:00:00 2001 From: rawpixel-vincent Date: Tue, 7 Jan 2025 15:38:07 +0700 Subject: [PATCH] fix: exception thrown in useMediaQuery when unsupported, add option to disable the hook --- src/useMediaQuery/index.dom.test.ts | 95 +++++++++++++++++----- src/useMediaQuery/index.ssr.test.ts | 7 ++ src/useMediaQuery/index.ts | 44 +++++++++- src/useScreenOrientation/index.ssr.test.ts | 7 ++ src/useScreenOrientation/index.ts | 6 ++ 5 files changed, 136 insertions(+), 23 deletions(-) diff --git a/src/useMediaQuery/index.dom.test.ts b/src/useMediaQuery/index.dom.test.ts index 4fa343a3..194e713e 100644 --- a/src/useMediaQuery/index.dom.test.ts +++ b/src/useMediaQuery/index.dom.test.ts @@ -1,16 +1,28 @@ 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'; +type MatchMediaMock = MediaQueryList & { + matches: boolean; + addEventListener: Mock; + removeEventListener: Mock; + dispatchEvent: Mock; +}; + 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 MatchMediaMock, + ); vi.stubGlobal('matchMedia', matchMediaMock); @@ -30,17 +42,45 @@ describe('useMediaQuery', () => { expect(result.error).toBeUndefined(); }); + it('should return undefined and not thrown on unsupported when not enabled', () => { + const spy = vi.fn(); + vi.stubGlobal('console', { + error: spy, + }); + 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(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true); + 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(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.current).toBe(undefined); + expect(result2.current).toBe(undefined); + expect(result2.error).toBeUndefined(); + unmount(); + unmount2(); + expect(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true); + 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})); + const {result} = renderHook(() => useMediaQuery('max-width : 768px', {initializeWithValue: false})); expect(result.all.length).toBe(2); expect(result.all[0]).toBe(undefined); expect(result.current).toBe(false); }); it('should return value on first render, if initializeWithValue is true', () => { - const {result} = renderHook(() => - useMediaQuery('max-width : 768px', {initializeWithValue: true})); + const {result} = renderHook(() => useMediaQuery('max-width : 768px', {initializeWithValue: true})); expect(result.all.length).toBe(1); expect(result.current).toBe(false); }); @@ -97,12 +137,9 @@ describe('useMediaQuery', () => { it('should unsubscribe from previous mql when query changed', () => { const {result: result1} = renderHook(() => useMediaQuery('max-width : 768px')); const {result: result2} = renderHook(() => useMediaQuery('max-width : 768px')); - const {result: result3, rerender: rerender3} = renderHook( - ({query}) => useMediaQuery(query), - { - initialProps: {query: 'max-width : 768px'}, - }, - ); + const {result: result3, rerender: rerender3} = renderHook(({query}) => useMediaQuery(query), { + initialProps: {query: 'max-width : 768px'}, + }); expect(result1.current).toBe(false); expect(result2.current).toBe(false); expect(result3.current).toBe(false); @@ -147,4 +184,24 @@ describe('useMediaQuery', () => { unmount1(); expect(mql.removeEventListener).toHaveBeenCalledTimes(1); }); + + it('should not throw when media query is not supported', () => { + const spy = vi.fn(); + vi.stubGlobal('console', { + error: spy, + }); + const {result, unmount, rerender} = renderHook(() => useMediaQuery('(orientation: unsupported)', {initializeWithValue: true})); + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(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); + vi.unstubAllGlobals(); + vi.stubGlobal('matchMedia', matchMediaMock); + }); }); 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');