Skip to content

Commit

Permalink
fix: safari 13 exception thrown in useScreenOrientation, add enabled …
Browse files Browse the repository at this point in the history
…option to disable the match
  • Loading branch information
rawpixel-vincent committed Jan 4, 2025
1 parent 2eaa2cb commit 92d8832
Show file tree
Hide file tree
Showing 5 changed files with 117 additions and 13 deletions.
66 changes: 57 additions & 9 deletions src/useMediaQuery/index.dom.test.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -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}));
Expand Down Expand Up @@ -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);
});
});
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

0 comments on commit 92d8832

Please sign in to comment.