Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: exception thrown in useMediaQuery when unsupported, add option to disable the hook #1581

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
95 changes: 76 additions & 19 deletions src/useMediaQuery/index.dom.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions src/useMediaQuery/index.ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
44 changes: 40 additions & 4 deletions src/useMediaQuery/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,43 @@ import {isBrowser} from '../util/const.js';

const queriesMap = new Map<
string,
{mql: MediaQueryList; dispatchers: Set<Dispatch<boolean>>; listener: () => void}
{
mql: MediaQueryList;
dispatchers: Set<Dispatch<boolean>>;
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<Dispatch<boolean>>(),
listener: () => undefined as void,
};
}

const dispatchers = new Set<QueryStateSetter>();
const listener = () => {
for (const d of dispatchers) {
Expand Down Expand Up @@ -61,6 +91,7 @@ const queryUnsubscribe = (query: string, setState: QueryStateSetter): void => {

type UseMediaQueryOptions = {
initializeWithValue?: boolean;
enabled?: boolean;
};

/**
Expand All @@ -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<boolean | undefined>(() => {
if (initializeWithValue) {
if (initializeWithValue && enabled) {
let entry = queriesMap.get(query);
if (!entry) {
entry = createQueryEntry(query);
Expand All @@ -94,12 +126,16 @@ export function useMediaQuery(
});

useEffect(() => {
if (!enabled) {
return;
}

querySubscribe(query, setState);

return () => {
queryUnsubscribe(query, setState);
};
}, [query]);
}, [query, enabled]);

return state;
}
7 changes: 7 additions & 0 deletions src/useScreenOrientation/index.ssr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
6 changes: 6 additions & 0 deletions src/useScreenOrientation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ export type ScreenOrientation = 'portrait' | 'landscape';

type UseScreenOrientationOptions = {
initializeWithValue?: boolean;
enabled?: boolean;
};

/**
* Checks if screen is in `portrait` or `landscape` orientation.
*
* 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');
Expand Down