Skip to content

Commit 3f2db52

Browse files
fix: exception thrown in useMediaQuery when unsupported, add option to disable the hook
1 parent c82bc42 commit 3f2db52

File tree

5 files changed

+136
-23
lines changed

5 files changed

+136
-23
lines changed

src/useMediaQuery/index.dom.test.ts

+76-19
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import {act, renderHook} from '@testing-library/react-hooks/dom';
2-
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
2+
import {type Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
33
import {useMediaQuery} from '../index.js';
44

5+
type MatchMediaMock = MediaQueryList & {
6+
matches: boolean;
7+
addEventListener: Mock;
8+
removeEventListener: Mock;
9+
dispatchEvent: Mock;
10+
};
11+
512
describe('useMediaQuery', () => {
6-
const matchMediaMock = vi.fn((query: string) => ({
7-
matches: false,
8-
media: query,
9-
onchange: null,
10-
addEventListener: vi.fn(),
11-
removeEventListener: vi.fn(),
12-
dispatchEvent: vi.fn(),
13-
}));
13+
const matchMediaMock = vi.fn(
14+
(query: string) =>
15+
(query === '(orientation: unsupported)' ?
16+
undefined :
17+
{
18+
matches: false,
19+
media: query,
20+
onchange: null,
21+
addEventListener: vi.fn(),
22+
removeEventListener: vi.fn(),
23+
dispatchEvent: vi.fn(),
24+
}) as unknown as MatchMediaMock,
25+
);
1426

1527
vi.stubGlobal('matchMedia', matchMediaMock);
1628

@@ -30,17 +42,45 @@ describe('useMediaQuery', () => {
3042
expect(result.error).toBeUndefined();
3143
});
3244

45+
it('should return undefined and not thrown on unsupported when not enabled', () => {
46+
const spy = vi.fn();
47+
vi.stubGlobal('console', {
48+
error: spy,
49+
});
50+
const {result, rerender, unmount} = renderHook(() => useMediaQuery('max-width : 768px', {enabled: false}));
51+
const {result: result2, rerender: rerender2, unmount: unmount2} = renderHook(() => useMediaQuery('(orientation: unsupported)', {enabled: false}));
52+
expect(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true);
53+
expect(result.error).toBeUndefined();
54+
expect(result.current).toBe(undefined);
55+
expect(result2.error).toBeUndefined();
56+
expect(result2.current).toBe(undefined);
57+
rerender('max-width : 768px');
58+
rerender2('(orientation: unsupported)');
59+
expect(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true);
60+
expect(result.error).toBeUndefined();
61+
expect(result.current).toBe(undefined);
62+
expect(result2.current).toBe(undefined);
63+
expect(result2.error).toBeUndefined();
64+
unmount();
65+
unmount2();
66+
expect(spy.call.length === 0 || !spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true);
67+
expect(result.error).toBeUndefined();
68+
expect(result.current).toBe(undefined);
69+
expect(result2.error).toBeUndefined();
70+
expect(result2.current).toBe(undefined);
71+
vi.unstubAllGlobals();
72+
vi.stubGlobal('matchMedia', matchMediaMock);
73+
});
74+
3375
it('should return undefined on first render, if initializeWithValue is false', () => {
34-
const {result} = renderHook(() =>
35-
useMediaQuery('max-width : 768px', {initializeWithValue: false}));
76+
const {result} = renderHook(() => useMediaQuery('max-width : 768px', {initializeWithValue: false}));
3677
expect(result.all.length).toBe(2);
3778
expect(result.all[0]).toBe(undefined);
3879
expect(result.current).toBe(false);
3980
});
4081

4182
it('should return value on first render, if initializeWithValue is true', () => {
42-
const {result} = renderHook(() =>
43-
useMediaQuery('max-width : 768px', {initializeWithValue: true}));
83+
const {result} = renderHook(() => useMediaQuery('max-width : 768px', {initializeWithValue: true}));
4484
expect(result.all.length).toBe(1);
4585
expect(result.current).toBe(false);
4686
});
@@ -97,12 +137,9 @@ describe('useMediaQuery', () => {
97137
it('should unsubscribe from previous mql when query changed', () => {
98138
const {result: result1} = renderHook(() => useMediaQuery('max-width : 768px'));
99139
const {result: result2} = renderHook(() => useMediaQuery('max-width : 768px'));
100-
const {result: result3, rerender: rerender3} = renderHook(
101-
({query}) => useMediaQuery(query),
102-
{
103-
initialProps: {query: 'max-width : 768px'},
104-
},
105-
);
140+
const {result: result3, rerender: rerender3} = renderHook(({query}) => useMediaQuery(query), {
141+
initialProps: {query: 'max-width : 768px'},
142+
});
106143
expect(result1.current).toBe(false);
107144
expect(result2.current).toBe(false);
108145
expect(result3.current).toBe(false);
@@ -147,4 +184,24 @@ describe('useMediaQuery', () => {
147184
unmount1();
148185
expect(mql.removeEventListener).toHaveBeenCalledTimes(1);
149186
});
187+
188+
it('should not throw when media query is not supported', () => {
189+
const spy = vi.fn();
190+
vi.stubGlobal('console', {
191+
error: spy,
192+
});
193+
const {result, unmount, rerender} = renderHook(() => useMediaQuery('(orientation: unsupported)', {initializeWithValue: true}));
194+
expect(spy).toHaveBeenCalled();
195+
expect(spy.mock.calls.some((call: string[]) => call[0]?.includes?.('error: matchMedia'))).toBe(true);
196+
expect(result.error).toBeUndefined();
197+
expect(result.current).toBe(undefined);
198+
rerender();
199+
expect(result.error).toBeUndefined();
200+
expect(result.current).toBe(undefined);
201+
unmount();
202+
expect(result.error).toBeUndefined();
203+
expect(result.current).toBe(undefined);
204+
vi.unstubAllGlobals();
205+
vi.stubGlobal('matchMedia', matchMediaMock);
206+
});
150207
});

src/useMediaQuery/index.ssr.test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ describe('useMediaQuery', () => {
1818
useMediaQuery('max-width : 768px', {initializeWithValue: false}));
1919
expect(result.current).toBeUndefined();
2020
});
21+
22+
it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => {
23+
const {result} = renderHook(() =>
24+
useMediaQuery('max-width : 768px', {initializeWithValue: true, enabled: false}));
25+
expect(result.error).toBeUndefined();
26+
expect(result.current).toBeUndefined();
27+
});
2128
});

src/useMediaQuery/index.ts

+40-4
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,43 @@ import {isBrowser} from '../util/const.js';
33

44
const queriesMap = new Map<
55
string,
6-
{mql: MediaQueryList; dispatchers: Set<Dispatch<boolean>>; listener: () => void}
6+
{
7+
mql: MediaQueryList;
8+
dispatchers: Set<Dispatch<boolean>>;
9+
listener: () => void;
10+
}
711
>();
812

913
type QueryStateSetter = (matches: boolean) => void;
1014

1115
const createQueryEntry = (query: string) => {
1216
const mql = matchMedia(query);
17+
if (!mql) {
18+
if (
19+
typeof process === 'undefined' ||
20+
process.env === undefined ||
21+
process.env.NODE_ENV === 'development' ||
22+
process.env.NODE_ENV === 'test'
23+
) {
24+
console.error(`error: matchMedia('${query}') returned null, this means that the browser does not support this query or the query is invalid.`);
25+
}
26+
27+
return {
28+
mql: {
29+
onchange: null,
30+
matches: undefined as unknown as boolean,
31+
media: query,
32+
addEventListener: () => undefined as void,
33+
addListener: () => undefined as void,
34+
removeListener: () => undefined as void,
35+
removeEventListener: () => undefined as void,
36+
dispatchEvent: () => false as boolean,
37+
},
38+
dispatchers: new Set<Dispatch<boolean>>(),
39+
listener: () => undefined as void,
40+
};
41+
}
42+
1343
const dispatchers = new Set<QueryStateSetter>();
1444
const listener = () => {
1545
for (const d of dispatchers) {
@@ -61,6 +91,7 @@ const queryUnsubscribe = (query: string, setState: QueryStateSetter): void => {
6191

6292
type UseMediaQueryOptions = {
6393
initializeWithValue?: boolean;
94+
enabled?: boolean;
6495
};
6596

6697
/**
@@ -70,19 +101,20 @@ type UseMediaQueryOptions = {
70101
* @param options Hook options:
71102
* `initializeWithValue` (default: `true`) - Determine media query match state on first render. Setting
72103
* this to false will make the hook yield `undefined` on first render.
104+
* `enabled` (default: `true`) - Enable or disable the hook.
73105
*/
74106
export function useMediaQuery(
75107
query: string,
76108
options: UseMediaQueryOptions = {},
77109
): boolean | undefined {
78-
let {initializeWithValue = true} = options;
110+
let {initializeWithValue = true, enabled = true} = options;
79111

80112
if (!isBrowser) {
81113
initializeWithValue = false;
82114
}
83115

84116
const [state, setState] = useState<boolean | undefined>(() => {
85-
if (initializeWithValue) {
117+
if (initializeWithValue && enabled) {
86118
let entry = queriesMap.get(query);
87119
if (!entry) {
88120
entry = createQueryEntry(query);
@@ -94,12 +126,16 @@ export function useMediaQuery(
94126
});
95127

96128
useEffect(() => {
129+
if (!enabled) {
130+
return;
131+
}
132+
97133
querySubscribe(query, setState);
98134

99135
return () => {
100136
queryUnsubscribe(query, setState);
101137
};
102-
}, [query]);
138+
}, [query, enabled]);
103139

104140
return state;
105141
}

src/useScreenOrientation/index.ssr.test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@ describe('useScreenOrientation', () => {
1111
const {result} = renderHook(() => useScreenOrientation({initializeWithValue: false}));
1212
expect(result.error).toBeUndefined();
1313
});
14+
15+
it('should return undefined on first render, when not enabled and initializeWithValue is set to true', () => {
16+
const {result} = renderHook(() =>
17+
useScreenOrientation({initializeWithValue: true, enabled: false}));
18+
expect(result.error).toBeUndefined();
19+
expect(result.current).toBeUndefined();
20+
});
1421
});

src/useScreenOrientation/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@ export type ScreenOrientation = 'portrait' | 'landscape';
44

55
type UseScreenOrientationOptions = {
66
initializeWithValue?: boolean;
7+
enabled?: boolean;
78
};
89

910
/**
1011
* Checks if screen is in `portrait` or `landscape` orientation.
1112
*
1213
* As `Screen Orientation API` is still experimental and not supported by Safari, this
1314
* hook uses CSS3 `orientation` media-query to check screen orientation.
15+
* @param options Hook options:
16+
* `initializeWithValue` (default: `true`) - Determine screen orientation on first render. Setting
17+
* this to false will make the hook yield `undefined` on first render.
18+
* `enabled` (default: `true`) - Enable or disable the hook.
1419
*/
1520
export function useScreenOrientation(
1621
options?: UseScreenOrientationOptions,
1722
): ScreenOrientation | undefined {
1823
const matches = useMediaQuery('(orientation: portrait)', {
1924
initializeWithValue: options?.initializeWithValue ?? true,
25+
enabled: options?.enabled,
2026
});
2127

2228
return matches === undefined ? undefined : (matches ? 'portrait' : 'landscape');

0 commit comments

Comments
 (0)